From 48f65c75604a49053ba93741855ed71dda78ac93 Mon Sep 17 00:00:00 2001 From: Kijin Sung Date: Tue, 14 Jul 2015 15:17:36 +0900 Subject: [PATCH 1/4] Thoroughly comment existing behavior --- modules/file/file.controller.php | 49 ++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/modules/file/file.controller.php b/modules/file/file.controller.php index 8ded3a292..f92bf523b 100644 --- a/modules/file/file.controller.php +++ b/modules/file/file.controller.php @@ -286,34 +286,37 @@ class fileController extends file public function procFileOutput() { + // Get requsted file info $oFileModel = getModel('file'); $file_srl = Context::get('file_srl'); $file_key = Context::get('file_key'); - if(strstr($_SERVER['HTTP_USER_AGENT'], "Android")) $is_android = true; - if($is_android && $_SESSION['__XE_FILE_KEY_AND__'][$file_srl]) $session_key = '__XE_FILE_KEY_AND__'; - else $session_key = '__XE_FILE_KEY__'; $columnList = array('source_filename', 'uploaded_filename', 'file_size'); $file_obj = $oFileModel->getFile($file_srl, $columnList); + $file_size = $file_obj->file_size; + $filename = $file_obj->source_filename; - $uploaded_filename = $file_obj->uploaded_filename; - - if(!file_exists($uploaded_filename)) return $this->stop('msg_file_not_found'); + // Android <= 4.0 tries to download the same file twice, so we allow it + if(strstr($_SERVER['HTTP_USER_AGENT'], "Android")) + { + $is_android = true; + } + if($is_android && $_SESSION['__XE_FILE_KEY_AND__'][$file_srl]) + { + $session_key = '__XE_FILE_KEY_AND__'; + } + else + { + $session_key = '__XE_FILE_KEY__'; + } + // If not Android, we do not allow downloading the same file twice if(!$file_key || $_SESSION[$session_key][$file_srl] != $file_key) { unset($_SESSION[$session_key][$file_srl]); return $this->stop('msg_invalid_request'); } - $file_size = $file_obj->file_size; - $filename = $file_obj->source_filename; - if(strpos($_SERVER['HTTP_USER_AGENT'], 'MSIE') !== FALSE || (strpos($_SERVER['HTTP_USER_AGENT'], 'Windows') !== FALSE && strpos($_SERVER['HTTP_USER_AGENT'], 'Trident') !== FALSE && strpos($_SERVER['HTTP_USER_AGENT'], 'rv:') !== FALSE)) - { - $filename = rawurlencode($filename); - $filename = preg_replace('/\./', '%2e', $filename, substr_count($filename, '.') - 1); - } - if($is_android) { if($_SESSION['__XE_FILE_KEY__'][$file_srl]) $_SESSION['__XE_FILE_KEY_AND__'][$file_srl] = $file_key; @@ -321,11 +324,27 @@ class fileController extends file unset($_SESSION[$session_key][$file_srl]); + // Encode the filename for browsers that don't support RFC2231/5987 + if(strpos($_SERVER['HTTP_USER_AGENT'], 'MSIE') !== FALSE || (strpos($_SERVER['HTTP_USER_AGENT'], 'Windows') !== FALSE && strpos($_SERVER['HTTP_USER_AGENT'], 'Trident') !== FALSE && strpos($_SERVER['HTTP_USER_AGENT'], 'rv:') !== FALSE)) + { + $filename = rawurlencode($filename); + $filename = preg_replace('/\./', '%2e', $filename, substr_count($filename, '.') - 1); + } + + // Close context to prevent blocking the session Context::close(); + // Check if file exists + $uploaded_filename = $file_obj->uploaded_filename; + if(!file_exists($uploaded_filename)) + { + return $this->stop('msg_file_not_found'); + } + $fp = fopen($uploaded_filename, 'rb'); if(!$fp) return $this->stop('msg_file_not_found'); + // Set headers header("Cache-Control: "); header("Pragma: "); header("Content-Type: application/octet-stream"); @@ -335,7 +354,7 @@ class fileController extends file header('Content-Disposition: attachment; filename="'.$filename.'"'); header("Content-Transfer-Encoding: binary\n"); - // if file size is lager than 10MB, use fread function (#18675748) + // If file size is lager than 1MB, use fread function (#18675748) if(filesize($uploaded_filename) > 1024 * 1024) { while(!feof($fp)) echo fread($fp, 1024); From bd922c8aae8bec23d0d1747d7c2951e0b33e3925 Mon Sep 17 00:00:00 2001 From: Kijin Sung Date: Tue, 14 Jul 2015 16:38:35 +0900 Subject: [PATCH 2/4] Implement 304 Not Modified and 206 Partial Content for file downloads --- modules/file/file.controller.php | 77 +++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 17 deletions(-) diff --git a/modules/file/file.controller.php b/modules/file/file.controller.php index f92bf523b..b41028cc1 100644 --- a/modules/file/file.controller.php +++ b/modules/file/file.controller.php @@ -293,8 +293,30 @@ class fileController extends file $columnList = array('source_filename', 'uploaded_filename', 'file_size'); $file_obj = $oFileModel->getFile($file_srl, $columnList); - $file_size = $file_obj->file_size; + $filesize = $file_obj->file_size; $filename = $file_obj->source_filename; + $etag = md5($file_srl . $file_key . $_SERVER['HTTP_USER_AGENT']); + + // Check if file exists + $uploaded_filename = $file_obj->uploaded_filename; + if(!file_exists($uploaded_filename)) + { + return $this->stop('msg_file_not_found'); + } + + // If client sent an If-None-Match header with the correct ETag, do not download again + if(isset($_SERVER['HTTP_IF_NONE_MATCH']) && trim(trim($_SERVER['HTTP_IF_NONE_MATCH']), '\'"') === $etag) + { + header('HTTP/1.1 304 Not Modified'); + exit(); + } + + // If client sent an If-Modified-Since header with a recent modification date, do not download again + if(isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) > filemtime($uploaded_filename)) + { + header('HTTP/1.1 304 Not Modified'); + exit(); + } // Android <= 4.0 tries to download the same file twice, so we allow it if(strstr($_SERVER['HTTP_USER_AGENT'], "Android")) @@ -334,35 +356,56 @@ class fileController extends file // Close context to prevent blocking the session Context::close(); - // Check if file exists - $uploaded_filename = $file_obj->uploaded_filename; - if(!file_exists($uploaded_filename)) + // Open file + $fp = fopen($uploaded_filename, 'rb'); + if(!$fp) { return $this->stop('msg_file_not_found'); } - $fp = fopen($uploaded_filename, 'rb'); - if(!$fp) return $this->stop('msg_file_not_found'); + // Take care of pause and resume + if(isset($_SERVER['HTTP_RANGE']) && preg_match('/^bytes=(\d+)-(\d+)?/', $_SERVER['HTTP_RANGE'], $matches)) + { + $range_start = $matches[1]; + $range_end = $matches[2] ? $matches[2] : ($filesize - 1); + $range_length = $range_end - $range_start + 1; + if($range_length < 1 || $range_start < 0 || $range_start >= $filesize || $range_end >= $filesize) + { + header('HTTP/1.1 416 Requested Range Not Satisfiable'); + fclose($fp); + exit(); + } + fseek($fp, $range_start); + header('HTTP/1.1 206 Partial Content'); + header('Content-Range: bytes ' . $range_start . '-' . $range_end . '/' . $filesize); + } + else + { + $range_start = 0; + $range_length = $filesize - $range_start; + } + + // Clear buffer + while(ob_get_level()) ob_end_clean(); // Set headers - header("Cache-Control: "); + header("Cache-Control: private; max-age=3600"); header("Pragma: "); header("Content-Type: application/octet-stream"); header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT"); - header("Content-Length: " .(string)($file_size)); header('Content-Disposition: attachment; filename="'.$filename.'"'); - header("Content-Transfer-Encoding: binary\n"); + header('Content-Transfer-Encoding: binary'); + header('Content-Length: ' . $range_length); + header('Accept-Ranges: bytes'); + header('Etag: "' . $etag . '"'); - // If file size is lager than 1MB, use fread function (#18675748) - if(filesize($uploaded_filename) > 1024 * 1024) + // Print the file contents + for($offset = 0; $offset < $range_length; $offset += 4096) { - while(!feof($fp)) echo fread($fp, 1024); - fclose($fp); - } - else - { - fpassthru($fp); + $buffer_size = min(4096, $range_length - $offset); + echo fread($fp, $buffer_size); + flush(); } exit(); From 0dc114b5b21497425302f7dc0ce14f4e229dd78d Mon Sep 17 00:00:00 2001 From: Kijin Sung Date: Tue, 14 Jul 2015 17:23:27 +0900 Subject: [PATCH 3/4] Improve filename encoding for modern browsers that support RFC 5987 --- modules/file/file.controller.php | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/modules/file/file.controller.php b/modules/file/file.controller.php index b41028cc1..ca044e8e6 100644 --- a/modules/file/file.controller.php +++ b/modules/file/file.controller.php @@ -346,11 +346,24 @@ class fileController extends file unset($_SESSION[$session_key][$file_srl]); - // Encode the filename for browsers that don't support RFC2231/5987 - if(strpos($_SERVER['HTTP_USER_AGENT'], 'MSIE') !== FALSE || (strpos($_SERVER['HTTP_USER_AGENT'], 'Windows') !== FALSE && strpos($_SERVER['HTTP_USER_AGENT'], 'Trident') !== FALSE && strpos($_SERVER['HTTP_USER_AGENT'], 'rv:') !== FALSE)) + // Filename encoding for browsers that support RFC 5987 + if(preg_match('#(?:Chrome|Edge)/(\d+)\.#', $_SERVER['HTTP_USER_AGENT'], $matches) && $matches[1] >= 11) + { + $filename_param = "filename*=UTF-8''" . rawurlencode($filename) . '; filename="' . rawurlencode($filename) . '"'; + } + elseif(preg_match('#(?:Firefox|Safari|Trident)/(\d+)\.#', $_SERVER['HTTP_USER_AGENT'], $matches) && $matches[1] >= 6) + { + $filename_param = "filename*=UTF-8''" . rawurlencode($filename) . '; filename="' . rawurlencode($filename) . '"'; + } + // Filename encoding for browsers that do not support RFC 5987 + elseif(strpos($_SERVER['HTTP_USER_AGENT'], 'MSIE') !== FALSE) { $filename = rawurlencode($filename); - $filename = preg_replace('/\./', '%2e', $filename, substr_count($filename, '.') - 1); + $filename_param = 'filename="' . preg_replace('/\./', '%2e', $filename, substr_count($filename, '.') - 1) . '"'; + } + else + { + $filename_param = 'filename="' . $filename . '"'; } // Close context to prevent blocking the session @@ -394,7 +407,7 @@ class fileController extends file header("Content-Type: application/octet-stream"); header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT"); - header('Content-Disposition: attachment; filename="'.$filename.'"'); + header('Content-Disposition: attachment; ' . $filename_param); header('Content-Transfer-Encoding: binary'); header('Content-Length: ' . $range_length); header('Accept-Ranges: bytes'); From dda0ad41f70858428755bdcbeddb0508fee1d16b Mon Sep 17 00:00:00 2001 From: Kijin Sung Date: Tue, 14 Jul 2015 21:20:49 +0900 Subject: [PATCH 4/4] Create and verify file download keys without adding too much data to the session --- modules/file/file.controller.php | 55 +++++++++++++------------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/modules/file/file.controller.php b/modules/file/file.controller.php index ca044e8e6..1892b59d1 100644 --- a/modules/file/file.controller.php +++ b/modules/file/file.controller.php @@ -263,25 +263,30 @@ class fileController extends file } } } + // Call a trigger (before) $output = ModuleHandler::triggerCall('file.downloadFile', 'before', $file_obj); if(!$output->toBool()) return $this->stop(($output->message)?$output->message:'msg_not_permitted_download'); - - // 다운로드 후 (가상) // Increase download_count $args = new stdClass(); $args->file_srl = $file_srl; executeQuery('file.updateFileDownloadCount', $args); + // Call a trigger (after) $output = ModuleHandler::triggerCall('file.downloadFile', 'after', $file_obj); - $random = new Password(); - $file_key = $_SESSION['__XE_FILE_KEY__'][$file_srl] = $random->createSecureSalt(32, 'hex'); + // Redirect to procFileOutput using file key + if(!isset($_SESSION['__XE_FILE_KEY__']) || !is_string($_SESSION['__XE_FILE_KEY__']) || strlen($_SESSION['__XE_FILE_KEY__']) != 32) + { + $random = new Password(); + $_SESSION['__XE_FILE_KEY__'] = $random->createSecureSalt(32, 'hex'); + } + $file_key_data = $file_obj->file_srl . $file_obj->file_size . $file_obj->uploaded_filename . $_SERVER['REMOTE_ADDR'] . $_SERVER['HTTP_USER_AGENT']; + $file_key = substr(hash_hmac('sha256', $file_key_data, $_SESSION['__XE_FILE_KEY__']), 0, 32); header('Location: '.getNotEncodedUrl('', 'act', 'procFileOutput','file_srl',$file_srl,'file_key',$file_key)); Context::close(); exit(); - } public function procFileOutput() @@ -297,6 +302,18 @@ class fileController extends file $filename = $file_obj->source_filename; $etag = md5($file_srl . $file_key . $_SERVER['HTTP_USER_AGENT']); + // Check file key + if(strlen($file_key) != 32 || !isset($_SESSION['__XE_FILE_KEY__']) || !is_string($_SESSION['__XE_FILE_KEY__'])) + { + return $this->stop('msg_invalid_request'); + } + $file_key_data = $file_srl . $file_obj->file_size . $file_obj->uploaded_filename . $_SERVER['REMOTE_ADDR'] . $_SERVER['HTTP_USER_AGENT']; + $file_key_compare = substr(hash_hmac('sha256', $file_key_data, $_SESSION['__XE_FILE_KEY__']), 0, 32); + if($file_key !== $file_key_compare) + { + return $this->stop('msg_invalid_request'); + } + // Check if file exists $uploaded_filename = $file_obj->uploaded_filename; if(!file_exists($uploaded_filename)) @@ -318,34 +335,6 @@ class fileController extends file exit(); } - // Android <= 4.0 tries to download the same file twice, so we allow it - if(strstr($_SERVER['HTTP_USER_AGENT'], "Android")) - { - $is_android = true; - } - if($is_android && $_SESSION['__XE_FILE_KEY_AND__'][$file_srl]) - { - $session_key = '__XE_FILE_KEY_AND__'; - } - else - { - $session_key = '__XE_FILE_KEY__'; - } - - // If not Android, we do not allow downloading the same file twice - if(!$file_key || $_SESSION[$session_key][$file_srl] != $file_key) - { - unset($_SESSION[$session_key][$file_srl]); - return $this->stop('msg_invalid_request'); - } - - if($is_android) - { - if($_SESSION['__XE_FILE_KEY__'][$file_srl]) $_SESSION['__XE_FILE_KEY_AND__'][$file_srl] = $file_key; - } - - unset($_SESSION[$session_key][$file_srl]); - // Filename encoding for browsers that support RFC 5987 if(preg_match('#(?:Chrome|Edge)/(\d+)\.#', $_SERVER['HTTP_USER_AGENT'], $matches) && $matches[1] >= 11) {