= $user_id) { trigger_error('Bad parameter for generateUserFilenameUID()!'); } global $rs_session, $scramble_key; $filename_uid = ''; if (isset($rs_session)) { $filename_uid .= $rs_session; } $filename_uid .= $user_id; return substr(hash('sha256', $filename_uid . $scramble_key), 0, 15); } /** * Checks if a path is part of a whitelisted list of paths. This applies to both folders and files. * * Note: the function is not supposed to check/ validate the syntax of the path (ie. UNIX/ Windows) * * @param string $path Path which is going to be checked against whitelisted paths * @param array $whitelisted_paths List of whitelisted paths * * @return boolean */ function isPathWhitelisted($path, array $whitelisted_paths) { foreach ($whitelisted_paths as $whitelisted_path) { if (substr_compare($whitelisted_path, $path, 0, strlen($path)) === 0) { return true; } } return false; } /** * Return a checksum for the given file path. * * @param string $path Path to file * @param bool $forcefull Force use of whole file and ignore $file_checksums_50k setting * * @return string|false Return the checksum value, false otherwise. */ function get_checksum($path, $forcefull = false) { debug("get_checksum( \$path = {$path} );"); global $file_checksums_50k; if (!is_readable($path)) { return false; } # Generate the ID if ($file_checksums_50k && !$forcefull) { # Fetch the string used to generate the unique ID $use = filesize_unlimited($path) . "_" . file_get_contents($path, false, null, 0, 50000); $checksum = md5($use); } else { $checksum = md5_file($path); } return $checksum; } /** * Download remote file to the temp filestore location. * * @param string $url Source URL * @param string $key Optional key to use - to prevent conflicts when simultaneous calls use same file name * * @return string|bool Returns the new temp filestore location or false otherwise. */ function temp_local_download_remote_file(string $url, string $key = "") { $userref = $GLOBALS['userref'] ?? 0; if ($userref === 0) { return false; } if ($key != "" && preg_match('/\W+/', $key) !== 0) { // Block potential path traversal - allow only word characters. return false; } $url = trim($url); $url_original = $url; // Remove query string from URL $url = explode('?', $url); $url = reset($url); $path_parts = pathinfo(basename($url)); $filename = safe_file_name($path_parts['filename'] ?? ''); $extension = $path_parts['extension'] ?? ''; $filename .= ($extension !== '' ? ".{$extension}" : ''); // When the filename isn't valid, try and get from the HTTP header $check_in_header = strpos($filename, ".") === false && filter_var($url_original, FILTER_VALIDATE_URL); foreach ($GLOBALS['valid_upload_remote_sources'] as $valid_upload_remote_src) { // Support dynamic remote URL that may otherwise be mistaken with a file (e.g. pages/download.php) if (url_starts_with($valid_upload_remote_src, $url_original)) { $check_in_header = true; break; } } if ($check_in_header) { $urlinfo = parse_url($url); if (!isset($urlinfo["scheme"]) || !in_array($urlinfo["scheme"], ["http","https"])) { return false; } $headers = get_headers($url_original, true); foreach ($headers as $header => $headervalue) { if ( strtolower($header) == "content-disposition" // Check for double quotes first (e.g. attachment; filename="O'Malley's Bar.pdf") // OR Check for single quotes (e.g. attachment; filename='Space Travel.jpg') // OR Get file name up to first space && ( preg_match('/.*filename=[\"]([^\"]+)/', $headervalue, $matches) || preg_match('/.*filename=[\']([^\']+)/', $headervalue, $matches) || preg_match("/.*filename=([^ ]+)/", $headervalue, $matches) ) ) { $filename = $matches[1]; } } $extension = pathinfo(basename($filename), PATHINFO_EXTENSION); $filename = safe_file_name(pathinfo(basename($filename), PATHINFO_FILENAME)) . ".{$extension}"; } if (is_banned_extension($extension)) { debug('[temp_local_download_remote_file] WARN: Banned extension for ' . $filename); return false; } // Get temp location $tmp_uniq_path_id = $userref . "_" . $key . generateUserFilenameUID($userref); $tmp_dir = get_temp_dir(false) . "/remote_files/" . $tmp_uniq_path_id; if (!is_dir($tmp_dir)) { mkdir($tmp_dir, 0777, true); } $tmp_file_path = $tmp_dir . "/" . $filename; if ($tmp_file_path == $url) { // Already downloaded earlier by API call return $tmp_file_path; } // Download the file $GLOBALS['use_error_exception'] = true; try { if (copy($url_original, $tmp_file_path)) { return $tmp_file_path; } } catch (Throwable $t) { debug(sprintf( 'Failed to download remote file from "%s" to temp location "%s". Reason: %s', $url_original, $tmp_file_path, $t->getMessage() )); } unset($GLOBALS['use_error_exception']); return false; } /** * Basic check of uploaded file against list of allowed extensions * * @param array{name: string} $uploadedfile An element from the $_FILES PHP reserved variable * @param array $validextensions Array of valid extension strings */ function check_valid_file_extension(array $uploadedfile, array $validextensions): bool { $extension = parse_filename_extension($uploadedfile['name']); return in_array(strtolower($extension), array_map("strtolower", $validextensions)) && !is_banned_extension($extension); } /** * Is the given extension in the list of blocked extensions? * Also ensures extension is no longer than 10 characters due to resource.file_extension database column limit * * @param string $extension - file extension to check */ function is_banned_extension(string $extension): bool { return !( preg_match('/^[a-zA-Z0-9_-]{1,10}$/', $extension) === 1 && !in_array(mb_strtolower($extension), array_map('mb_strtolower', $GLOBALS['banned_extensions'])) ); } /** * Remove empty folder from path to file. Helpful to remove a temp directory once the file it was created to hold no longer exists. * This function should be called only once the directory to be removed is empty. * * @param string $path_to_file Full path to file in filestore. * * @return void */ function remove_empty_temp_directory(string $path_to_file = "") { if ($path_to_file != "" && !file_exists($path_to_file)) { $tmp_path_parts = pathinfo($path_to_file); $path_to_folder = str_replace(DIRECTORY_SEPARATOR . $tmp_path_parts['basename'], '', $path_to_file); rmdir($path_to_folder); } } /** * Confirm upload path is one of valid paths. * * @param string $file_path Upload path. * @param array $valid_upload_paths Array of valid upload paths to test against. * * @return bool true when path is valid else false */ function is_valid_upload_path(string $file_path, array $valid_upload_paths): bool { $orig_use_error_exception_val = $GLOBALS['use_error_exception'] ?? false; $GLOBALS["use_error_exception"] = true; try { $file_path = realpath($file_path); } catch (Exception $e) { debug("Invalid file path specified" . $e->getMessage()); return false; } $GLOBALS['use_error_exception'] = $orig_use_error_exception_val; foreach ($valid_upload_paths as $valid_upload_path) { if (is_dir($valid_upload_path)) { $checkpath = realpath($valid_upload_path); if (strpos($file_path, $checkpath) === 0) { return true; } } } return false; } /** * Validate the files on disk that are associated with the given resources * * @param array $resources Array of resource IDs or * array of resource data e.g, from search results * @param array $criteria Array with an array of callables for each resource with the * required return values in order to pass the check e.g. * 'file_exists" =>true for a file presence only check * * @return array $results An array with resource ID as the index and the results of the check as the value (boolean) * e.g. ["1234" => true, "1235" => false] */ function validate_resource_files(array $resources, array $criteria = []): array { $checkresources = isset($resources[0]["ref"]) ? $resources : get_resource_data_batch($resources); $results = []; foreach ($checkresources as $resource) { if (!is_int_loose($resource["ref"])) { $results[$resource["ref"]] = false; continue; } $filepath = get_resource_path($resource["ref"], true, '', false, $resource["file_extension"] ?? "jpg"); $results[$resource["ref"]] = false; foreach ($criteria as $criterion => $expected) { if (!is_callable($criterion)) { $results[$resource["ref"]] = false; // Not a valid check continue 2; } $cscheck = $expected === "%RESOURCE%file_checksum"; if (substr($expected, 0, 10) == "%RESOURCE%") { // $expected is a resource table column $expected = $resource[substr($expected, 10)]; } $testresult = call_user_func($criterion, $filepath); if ($cscheck && ($expected === null || $expected === "")) { // No checksum is currently recorded. Update it now that it's been calculated $results[$resource["ref"]] = true; debug("Skipping checksum check for resource " . $resource["ref"] . " - no existing checksum"); ps_query("UPDATE resource SET file_checksum = ? WHERE ref = ?", ['s', $testresult, 'i', $resource["ref"]]); continue; } $results[$resource["ref"]] = $testresult === $expected; if ($results[$resource["ref"]] === false) { debug($resource["ref"] . " failed integrity check. Expected: " . $criterion . "=" . $expected . ", got : " . $testresult); // No need to continue with other $criteria as check has failed continue 2; } } } return $results; } /** * Check if a given file path is from a valid RS accessible location * * @param string $path * @param array $override_paths Override checking of the default RS paths to check a specific location only. */ function is_valid_rs_path(string $path, array $override_paths = []): bool { debug_function_call(__FUNCTION__, func_get_args()); if ($GLOBALS["config_windows"]) { $path = str_replace("\\", "/", $path); } $sourcerealpath = realpath($path); $source_path_not_real = !$sourcerealpath || !file_exists($sourcerealpath); debug('source_path_not_real = ' . json_encode($source_path_not_real)); $checkname = $path; $pathinfo = pathinfo($path); if (($pathinfo["extension"] ?? "") === "icc") { // ResourceSpace generated .icc files have a double extension, need to strip extension again before checking $checkname = $pathinfo["filename"] ?? ""; } debug("checkname = {$checkname}"); if ( $source_path_not_real && !(preg_match('/^(?!\.)(?!.*\.$)(?!.*\.\.)[a-zA-Z0-9_\-[:space:]\/:.]+$/', ($pathinfo['dirname'] ?? "")) && is_safe_basename($checkname)) ) { debug('Invalid non-existing path'); return false; } // Check if path contains symlinks, if so don't use the value returned by realpath() as it is unlikely to match the expected paths $symlink = false; $path_parts = array_filter(explode("/", $path)); if ($GLOBALS["config_windows"]) { $checkpath = ""; } else { $checkpath = "/"; } foreach ($path_parts as $path_part) { $checkpath .= $path_part; if (!file_exists($checkpath)) { break; } if (check_symlink($checkpath)) { debug("{$checkpath} is a symlink"); $symlink = true; break; } $checkpath .= "/"; } $path_to_validate = ($source_path_not_real || $symlink) ? $path : $sourcerealpath; debug("path_to_validate = {$path_to_validate}"); if (count($override_paths) > 0) { $default_paths = $override_paths; } else { $default_paths = [ dirname(__DIR__) . '/gfx', $GLOBALS['storagedir'], $GLOBALS['syncdir'], $GLOBALS['fstemplate_alt_storagedir'], ]; if (isset($GLOBALS['tempdir'])) { $default_paths[] = $GLOBALS['tempdir']; } } $allowed_paths = array_filter(array_map('trim', array_unique($default_paths))); debug('allowed_paths = ' . implode(', ', $allowed_paths)); foreach ($allowed_paths as $allowed_path) { debug("Iter allowed path - {$allowed_path}"); $validpath = ($source_path_not_real || $symlink) ? $allowed_path : realpath($allowed_path); if ($GLOBALS["config_windows"]) { $allowed_path = str_replace("\\", "/", $allowed_path); $validpath = str_replace("\\", "/", $validpath); $path_to_validate = str_replace("\\", "/", $path_to_validate); } debug("validpath = {$validpath}"); debug("path_to_validate = {$path_to_validate}"); if ($validpath !== false && mb_strpos($path_to_validate, $validpath) === 0) { debug('Path allowed'); return true; } } debug('Default as an invalid path'); return false; } /** * Validation helper function to determine if a path base name is unsafe (e.g OS command injection). * Very strict, limited to specific characters only. Should only be used for filenames originating in ResourceSpace. */ function is_safe_basename(string $val): bool { $file_name = pathinfo($val, PATHINFO_FILENAME); return safe_file_name($file_name) === str_replace(' ', '_', $file_name) && !is_banned_extension(parse_filename_extension($val)); } // phpcs:disable enum ProcessFileUploadErrorCondition { case MissingSourceFile; case EmptySourceFile; case InvalidUploadPath; case SpecialFile; case InvalidExtension; case MimeTypeMismatch; case FileMoveFailure; /** * Translate error condition to users' language. * @param array $lang Language string mapping (i.e. the global $lang var) * @return string The translated version or the conditions' name if not found in the map */ public function i18n(array $lang): string { return $lang["error_file_upload_cond-{$this->name}"] ?? $this->name; } } // phpcs:enable /** * High level function which can handle processing file uploads. * * @param SplFileInfo|array{name: string, full_path: string, type: string, tmp_name: string, error: int, size: int} $source * @param array{ * allow_extensions?: list, * file_move?: 'move_uploaded_file'|'rename'|'copy', * mime_file_based_detection?: bool, * } $processor Processors which can override different parts of the main logic (e.g. allow specific extensions) * * @return array{success: bool, error?: ProcessFileUploadErrorCondition} */ function process_file_upload(SplFileInfo|array $source, SplFileInfo $destination, array $processor): array { if ($source instanceof SplFileInfo) { $source_file_name = $source->getFilename(); $source_file_path = $source->getRealPath(); $source_is_file = $source->isFile(); $source_file_size = $source_is_file ? $source->getSize() : 0; $file_move_processor = $processor['file_move'] ?? 'rename'; } else { $source_file_name = $source['name']; $source_file_path = $source['tmp_name']; $source_is_file = file_exists($source_file_path); $source_file_size = $source_is_file ? filesize($source_file_path) : 0; $file_move_processor = 'move_uploaded_file'; if (!is_uploaded_file($source['tmp_name'])) { trigger_error('Invalid $source input. For files not uploaded via HTTP POST, please use SplFileInfo'); exit(); } } $fail_due_to = static fn(ProcessFileUploadErrorCondition $cond): array => ['success' => false, 'error' => $cond]; // Source validation if (!$source_is_file) { debug("Missing source file - {$source_file_path}"); return $fail_due_to(ProcessFileUploadErrorCondition::MissingSourceFile); } elseif ($source_file_size === 0) { return $fail_due_to(ProcessFileUploadErrorCondition::EmptySourceFile); } elseif ( !is_valid_upload_path( $source_file_path, [...$GLOBALS['valid_upload_paths'], ini_get('upload_tmp_dir'), sys_get_temp_dir()] ) ) { debug("[WARN] Invalid upload path detected - {$source_file_path}"); return $fail_due_to(ProcessFileUploadErrorCondition::InvalidUploadPath); } // Check for "special" files if ( array_intersect( [ 'crossdomain.xml', 'clientaccesspolicy.xml', '.htaccess', '.htpasswd', ], [$source_file_name] ) !== [] ) { return $fail_due_to(ProcessFileUploadErrorCondition::SpecialFile); } // Extension validation $source_file_ext = parse_filename_extension($source_file_name); if ( ( isset($processor['allow_extensions']) && $processor['allow_extensions'] !== [] && !check_valid_file_extension(['name' => $source_file_name], $processor['allow_extensions']) ) || is_banned_extension($source_file_ext) ) { return $fail_due_to(ProcessFileUploadErrorCondition::InvalidExtension); } // Check content (MIME) type based on the file received (don't trust the header from the upload) $mime_file_based_detection = $processor['mime_file_based_detection'] ?? true; $mime_type_by_ext = get_mime_types_by_extension($source_file_ext); if ($mime_type_by_ext === []) { log_activity( "Unknown MIME type for file extension '{$source_file_ext}'", LOG_CODE_SYSTEM, get_mime_type($source_file_path, $source_file_ext, true)[0] ); /* todo: Drop this overriding once we have a better MIME type database (e.g. in 3 releases from now based on the activity log entries). This was temporarily added for v10.6 to avoid multiple failed uploads due to this new check. */ $mime_file_based_detection = false; } if ( $mime_file_based_detection && array_intersect($mime_type_by_ext, get_mime_type($source_file_path, $source_file_ext, true)) === [] ) { debug("MIME type mismatch for file '{$source_file_name}'"); return $fail_due_to(ProcessFileUploadErrorCondition::MimeTypeMismatch); } // Destination processing $dest_path = $destination->isFile() ? $destination->getRealPath() : $destination->getPathname(); if ($destination->isDir()) { trigger_error('Destination path must be for a file, not a directory!'); exit(); } elseif (!(is_valid_rs_path($dest_path) && is_safe_basename($destination->getBasename()))) { debug("Destination path '{$dest_path}' not allowed!"); trigger_error('Destination path not allowed'); exit(); } elseif (array_intersect(['move_uploaded_file', 'rename', 'copy'], [$file_move_processor]) === []) { trigger_error('Invalid upload (file move) processor'); exit(); } elseif ($file_move_processor($source_file_path, $dest_path)) { return ['success' => true]; } else { debug("Unable to move file uploaded FROM '{$source_file_path}' TO '$dest_path'"); return $fail_due_to(ProcessFileUploadErrorCondition::FileMoveFailure); } } /** * Parse file name (can include path, although it's unnecessary) to prevent known security bypasses associated with * extensions, such as: * - Double extensions, e.g. .jpg.php * - Null bytes, e.g. .php%00.jpg, where .jpg gets truncated and .php becomes the new extension * - Using Windows (DOS) 8.3 short path feature where it's possible to replace existing files by using their shortname * (e.g. ".htaccess" can be replaced by "HTACCE~1") */ function parse_filename_extension(string $filename): string { $orig_use_error_exception_val = $GLOBALS['use_error_exception'] ?? false; $GLOBALS["use_error_exception"] = true; try { $finfo = new SplFileInfo($filename); } catch (Throwable $t) { debug("[WARN] Bad file '{$filename}'. Reason: {$t->getMessage()}"); return ''; } $GLOBALS['use_error_exception'] = $orig_use_error_exception_val; /* Windows (DOS) 8.3 short paths (e.g. "HTACCE~1" = ".htaccess"). Example file info in such scenario: Filename is: HTACCE~1 Path is: (note, depends if input is a file name with path) Path name is: HTACCE~1 Real path is: C:\path\to\.htaccess */ if (preg_match('/^[A-Z0-9]{1,6}~([A-Z0-9]?)(\.[A-Z0-9_]{1,3})?$/i', $finfo->getFilename()) === 1) { if ($finfo->getRealPath() !== false && basename($finfo->getRealPath()) !== $finfo->getFilename()) { return parse_filename_extension($finfo->getRealPath()); } else { // Invalid if not a real file to avoid potential exploits debug("[WARN] Windows (DOS) 8.3 short path for non-existent file '{$filename}' - considered invalid"); return ''; } } // Invalidate if it's a hidden file without an extension (e.g .htaccess or .htpasswd) which would be incorrectly // picked up as an extension if (trim($finfo->getBasename($finfo->getExtension())) === '.') { debug("Hidden file '{$filename}' without an extension - considered invalid"); return ''; } return $finfo->getExtension(); } /** * Delete old files and folders from tempo directory based on the configured $purge_temp_folder_age value * Affects filestore/tmp, $storagedir/tmp or the configured $tempdir directory */ function delete_temp_files(): void { if ($GLOBALS["purge_temp_folder_age"] === 0) { // Disabled return; } // Set up array of folders to scan $folderstoscan = []; $folderstoscan[] = get_temp_dir(); $modified_folderstoscan = hook("add_folders_to_delete_from_temp", "", array($folderstoscan)); if (is_array($modified_folderstoscan) && !empty($modified_folderstoscan)) { $folderstoscan = $modified_folderstoscan; } // Set up array of folders to exclude $excludepaths = []; if (isset($GLOBALS["geo_tile_cache_directory"])) { $excludepaths[] = $GLOBALS["geo_tile_cache_directory"]; } else { $excludepaths[] = get_temp_dir() . "tiles"; } if (DOWNLOAD_FILE_LIFETIME > $GLOBALS["purge_temp_folder_age"]) { $excludepaths[] = get_temp_dir(false, "user_downloads"); } // Set up arrays to hold items to delete $folderstodelete = []; $filestodelete = []; foreach ($folderstoscan as $foldertoscan) { if (!file_exists($foldertoscan)) { continue; } $foldercontents = new DirectoryIterator($foldertoscan); foreach ($foldercontents as $object) { if (time() - $object->getMTime() > $GLOBALS["purge_temp_folder_age"] * 24 * 60 * 60) { $tmpfilename = $object->getFilename(); if ($object->isDot()) { continue; } foreach ($excludepaths as $excludepath) { if ( ($tmpfilename == $excludepath) || strpos($object->getRealPath(), $excludepath) == 0 ) { continue 2; } } if ($object->isDir()) { $folderstodelete[] = $foldertoscan . DIRECTORY_SEPARATOR . $tmpfilename; } elseif ($object->isFile()) { $filestodelete[] = $foldertoscan . DIRECTORY_SEPARATOR . $tmpfilename; } } } } foreach ($folderstodelete as $foldertodelete) { // Extra check that folder is in an expected path if (strpos($foldertodelete, $GLOBALS["storagedir"]) === false && strpos($foldertodelete, $GLOBALS["tempdir"]) === false && strpos($foldertodelete, 'filestore/tmp') === false ) { continue; } $success = rcRmdir($foldertodelete); if ('cli' == PHP_SAPI) { echo " - deleting directory " . $foldertodelete . " - " . ($success ? "SUCCESS" : "FAILED") . PHP_EOL; } debug(" - deleting directory " . $foldertodelete . " - " . ($success ? "SUCCESS" : "FAILED")); } foreach ($filestodelete as $filetodelete) { // Extra check that file is in an expected path if (strpos($filetodelete, $GLOBALS["storagedir"]) === false && strpos($filetodelete, $GLOBALS["tempdir"]) === false && strpos($filetodelete, 'filestore/tmp') === false ) { continue; } $success = try_unlink($filetodelete); if ('cli' == PHP_SAPI) { echo " - deleting file " . $filetodelete . " - " . ($success ? "SUCCESS" : "FAILED") . PHP_EOL; } debug(" - deleting file " . $filetodelete . " - " . ($success ? "SUCCESS" : "FAILED")); } } /** * Are the arguments set in $archiver_settings["arguments"] permitted? * Allows word characters, '@', and '-' only * * @param string Argument string * */ function permitted_archiver_arguments($string): bool { return preg_match('/[^\@\-\w]/', $string) === 0; } /** * Check if a given path is absolute or contains a symlink or junction * is_link() does not accurately detect junction links on Windows systems * instead we check if the output from stat() and lstat() differ. * * @param string $checkpath * @return bool */ function check_symlink(string $checkpath): bool { if ($GLOBALS["config_windows"]) { return stat($checkpath) != lstat($checkpath); } else { return is_link($checkpath); } }