From e881ddb458ea610888406b35f3251e1b084c78b6 Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Fri, 25 Sep 2026 21:31:47 +0200 Subject: [PATCH] Keep uploads outside the web root. save_uploaded_file() read, and prune_images() deleted, any path in the "uploaded" POST field. Uploads now go to UPLOAD_DIR under a random name that only the session knows. --- .env.example | 4 ++++ display.php | 20 ++++++++++++++++- get_data.lib.php | 48 +++++++++++++++++++++++++++++++++++++++++ publish/index.php | 8 ++++++- publish/post.lib.php | 48 ++++++++++++++++------------------------- publish/preview.inc.php | 15 ++++++------- publish/write.inc.php | 2 +- 7 files changed, 104 insertions(+), 41 deletions(-) diff --git a/.env.example b/.env.example index 8b298a2..65bf9f4 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,7 @@ RECAPTCHA_SECRET= # Google Analytics tracking ID. Leave it empty to turn analytics off. GOOGLE_ANALYTICS_ID= + +# Directory for uploaded images waiting to be saved. Keep it outside the +# web root. The default is xxx-uploads in the system temporary directory. +UPLOAD_DIR= diff --git a/display.php b/display.php index bc52b1c..6c670ff 100644 --- a/display.php +++ b/display.php @@ -14,7 +14,25 @@ if (isset($_SESSION['editor'])) { $is_admin = FALSE; } -display_img(get_id()); +if (isset($_GET['pending'])) + display_pending_img(); +else + display_img(get_id()); + +// Show the image this session uploaded at the preview step +function display_pending_img() { + $file = pending_upload(); + $info = $file ? getimagesize($file) : FALSE; + if (!$info) { + header("HTTP/1.1 404 Not Found"); + return; + } + header("Content-type: ".$info['mime']); + header("Content-length: ".filesize($file)); + header("X-Content-Type-Options: nosniff"); + header("Cache-Control: private, no-store"); + readfile($file); +} function display_img($id) { global $db, $is_admin, $queue; diff --git a/get_data.lib.php b/get_data.lib.php index 057fe1a..ced0da3 100644 --- a/get_data.lib.php +++ b/get_data.lib.php @@ -350,3 +350,51 @@ function which_table($move = FALSE, $action = 'read') { return 'xxx'; } } + +/* + * Uploaded images wait in a directory outside the web root until the + * story is saved. The session holds the random file name, so the client + * never sends a file path. + */ +function upload_dir() { + $dir = rtrim(config('UPLOAD_DIR') ?: sys_get_temp_dir() . '/xxx-uploads', '/'); + if (!is_dir($dir)) + mkdir($dir, 0700, TRUE); + return $dir; +} + +function is_upload_name($name) { + return is_string($name) && preg_match('/^[0-9a-f]{32}$/', $name); +} + +/* + * Move a file from $_FILES into the upload directory, and remember it + * in the session. Returns the new path, or FALSE. + */ +function store_upload($tmp_file) { + $name = bin2hex(random_bytes(16)); + $file = upload_dir() . '/' . $name; + if (!move_uploaded_file($tmp_file, $file)) + return FALSE; + $_SESSION['pending_upload'] = $name; + return $file; +} + +/* + * Return the path of the image this session uploaded earlier, or FALSE. + */ +function pending_upload() { + $name = $_SESSION['pending_upload'] ?? NULL; + if (!is_upload_name($name)) + return FALSE; + $file = upload_dir() . '/' . $name; + return is_file($file) ? $file : FALSE; +} + +/* + * URL that shows the pending image; the name part stops browsers from + * showing an older upload from their cache. + */ +function pending_upload_url() { + return '/display?pending=' . substr($_SESSION['pending_upload'] ?? '', 0, 8); +} diff --git a/publish/index.php b/publish/index.php index 2215b0f..9a23d27 100644 --- a/publish/index.php +++ b/publish/index.php @@ -1,4 +1,10 @@ - + diff --git a/publish/post.lib.php b/publish/post.lib.php index 8e223da..b0a820b 100644 --- a/publish/post.lib.php +++ b/publish/post.lib.php @@ -152,44 +152,32 @@ function get_mimetype($image_file) { } function save_uploaded_file() { - // Save uploaded file to new, temporary location + // A new upload in this request if (is_uploaded_file($_FILES['upload']['tmp_name'] ?? '')) { - $tmp_file = $_FILES['upload']['tmp_name']; - $new_file = $_SERVER['DOCUMENT_ROOT'] . "/graphics" . $tmp_file; - $end_dir = dirname($new_file); - if (!is_dir($end_dir)) - mkdir($end_dir, "0644", 1); - $image_file = $new_file; - move_uploaded_file($tmp_file, $image_file); - - // File has already been uploaded, get location - } else if (isset($_POST['uploaded']) && - is_file($_SERVER['DOCUMENT_ROOT'] . $_POST['uploaded'])) { - $image_file = realpath($_SERVER['DOCUMENT_ROOT'] . $_POST['uploaded']); + return store_upload($_FILES['upload']['tmp_name']); } - - if (isset($image_file)) { - return $image_file; - } else { - return FALSE; + // An image uploaded at the preview step; the session knows which one + if (($_POST['uploaded'] ?? '') === 'pending') { + return pending_upload(); } + return FALSE; } function prune_images($image_file) { - // Delete image from filesystem, and cleanup anything older than $age - $age = '86400'; // a day old - $cleanup_dir = dirname($image_file); - unlink($image_file); + // Delete the saved image, and anything older than $age. + // Only ever touch upload files inside the upload directory. + $age = 86400; // a day old + $dir = upload_dir(); + if (dirname($image_file) === $dir && is_upload_name(basename($image_file))) { + @unlink($image_file); + } + unset($_SESSION['pending_upload']); - if ( $dir_handle = @opendir($cleanup_dir) ) { - while (($cleanup_file = readdir ($dir_handle)) !== FALSE) { - if ($cleanup_file == '.' || $cleanup_file == '..') continue; - $cleanup_file = $cleanup_dir . "/". $cleanup_file; - if (@filemtime($cleanup_file) < (time() - $age)) { - @unlink($cleanup_file); - } + foreach (glob($dir . '/*') as $cleanup_file) { + if (is_upload_name(basename($cleanup_file)) && + @filemtime($cleanup_file) < (time() - $age)) { + @unlink($cleanup_file); } - closedir($dir_handle); } } diff --git a/publish/preview.inc.php b/publish/preview.inc.php index 21b152e..8c34c7b 100644 --- a/publish/preview.inc.php +++ b/publish/preview.inc.php @@ -23,14 +23,13 @@ if (isset($_POST['g-recaptcha-response'])) { echo '

Please review your submission. Once you\'ve finished, please click "Submit" at the bottom of the page, or click "Preview" to check it again.

'; -if (is_uploaded_file($_FILES['upload']['tmp_name'] ?? '') &&!isset($validate['upload'])) { - $tmp_file = $_FILES['upload']['tmp_name']; - $upload = getimagesize($tmp_file); - $new_file = $_SERVER['DOCUMENT_ROOT'] . "/graphics" . $tmp_file; - move_uploaded_file($tmp_file, $new_file); - $file_src = "/graphics" .$tmp_file; -} else if (isset($uploaded) && is_file($_SERVER['DOCUMENT_ROOT'] . $uploaded) && !$remove_img) { - $file_src = $uploaded; +// Keep a new image outside the web root until the story is saved; +// display.php shows it to this session only. +if (is_uploaded_file($_FILES['upload']['tmp_name'] ?? '') && !isset($validate['upload'])) { + if (store_upload($_FILES['upload']['tmp_name'])) + $file_src = pending_upload_url(); +} else if (isset($uploaded) && $uploaded === 'pending' && pending_upload() && !$remove_img) { + $file_src = pending_upload_url(); } echo display_stories(); diff --git a/publish/write.inc.php b/publish/write.inc.php index e9107c1..d277ec0 100644 --- a/publish/write.inc.php +++ b/publish/write.inc.php @@ -60,7 +60,7 @@ echo (isset($story) ? $story : '') '; + echo ' '; } else { echo ' '; }