mirror of https://github.com/itflow-org/itflow
Update Backup / Restore, now streams backup and restore to disk instead of memory causing memory to run out, sets timeout limit to unlimited, checks backup file contents for anything bad, use php instead shell exec for import of db, added .htaccess for apache to prevent php execution in /uploads/ directory as this is intended for file download only
This commit is contained in:
parent
fbf3346052
commit
ed589ef65b
|
|
@ -2,185 +2,304 @@
|
|||
|
||||
/*
|
||||
* ITFlow - GET/POST request handler for DB / master key backup
|
||||
* Rewritten with streaming SQL dump, component checksums, safer zipping, and better headers.
|
||||
*/
|
||||
|
||||
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
|
||||
|
||||
require_once "../includes/app_version.php";
|
||||
|
||||
if (isset($_GET['download_backup'])) {
|
||||
|
||||
validateCSRFToken($_GET['csrf_token']);
|
||||
|
||||
$timestamp = date('YmdHis');
|
||||
$baseName = "itflow_$timestamp";
|
||||
// --- Optional performance levers for big backups ---
|
||||
@set_time_limit(0);
|
||||
if (function_exists('ini_set')) {
|
||||
@ini_set('memory_limit', '1024M');
|
||||
}
|
||||
|
||||
// === 0. Scoped cleanup ===
|
||||
$cleanupFiles = [];
|
||||
/**
|
||||
* Write a line to a file handle with newline.
|
||||
*/
|
||||
function fwrite_ln($fh, string $s): void {
|
||||
fwrite($fh, $s);
|
||||
fwrite($fh, PHP_EOL);
|
||||
}
|
||||
|
||||
$registerTempFileForCleanup = function ($file) use (&$cleanupFiles) {
|
||||
$cleanupFiles[] = $file;
|
||||
};
|
||||
|
||||
register_shutdown_function(function () use (&$cleanupFiles) {
|
||||
foreach ($cleanupFiles as $file) {
|
||||
if (is_file($file)) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// === 1. Local helper function: zipFolder
|
||||
$zipFolder = function ($folderPath, $zipFilePath) {
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== TRUE) {
|
||||
error_log("Failed to open zip file: $zipFilePath");
|
||||
http_response_code(500);
|
||||
exit("Internal Server Error: Cannot open zip archive.");
|
||||
}
|
||||
|
||||
$folderPath = realpath($folderPath);
|
||||
if (!$folderPath) {
|
||||
error_log("Invalid folder path: $folderPath");
|
||||
http_response_code(500);
|
||||
exit("Internal Server Error: Invalid folder path.");
|
||||
}
|
||||
|
||||
$files = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($folderPath),
|
||||
RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (!$file->isDir()) {
|
||||
$filePath = $file->getRealPath();
|
||||
$relativePath = substr($filePath, strlen($folderPath) + 1);
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
};
|
||||
|
||||
// === 2. Create all temp files
|
||||
$sqlFile = tempnam(sys_get_temp_dir(), $baseName . "_sql_");
|
||||
$uploadsZip = tempnam(sys_get_temp_dir(), $baseName . "_uploads_");
|
||||
$versionFile = tempnam(sys_get_temp_dir(), $baseName . "_version_");
|
||||
$finalZip = tempnam(sys_get_temp_dir(), $baseName . "_backup_");
|
||||
|
||||
foreach ([$sqlFile, $uploadsZip, $versionFile, $finalZip] as $f) {
|
||||
$registerTempFileForCleanup($f);
|
||||
chmod($f, 0600);
|
||||
/**
|
||||
* Stream a SQL dump of schema and data into $sqlFile.
|
||||
* - Tables first (DROP + CREATE + INSERTs)
|
||||
* - Views (DROP VIEW + CREATE VIEW)
|
||||
* - Triggers (DROP TRIGGER + CREATE TRIGGER)
|
||||
*
|
||||
* NOTE: Routines/events are not dumped here. Add if needed.
|
||||
*/
|
||||
function dump_database_streaming(mysqli $mysqli, string $sqlFile): void {
|
||||
$fh = fopen($sqlFile, 'wb');
|
||||
if (!$fh) {
|
||||
http_response_code(500);
|
||||
exit("Cannot open dump file");
|
||||
}
|
||||
|
||||
// === 3. Generate SQL Dump
|
||||
$sqlContent = "-- UTF-8 + Foreign Key Safe Dump\n";
|
||||
$sqlContent .= "SET NAMES 'utf8mb4';\n";
|
||||
$sqlContent .= "SET foreign_key_checks = 0;\n\n";
|
||||
// Preamble
|
||||
fwrite_ln($fh, "-- UTF-8 + Foreign Key Safe Dump");
|
||||
fwrite_ln($fh, "SET NAMES 'utf8mb4';");
|
||||
fwrite_ln($fh, "SET FOREIGN_KEY_CHECKS = 0;");
|
||||
fwrite_ln($fh, "SET UNIQUE_CHECKS = 0;");
|
||||
fwrite_ln($fh, "SET AUTOCOMMIT = 0;");
|
||||
fwrite_ln($fh, "");
|
||||
|
||||
// Gather tables and views
|
||||
$tables = [];
|
||||
$res = $mysqli->query("SHOW TABLES");
|
||||
$views = [];
|
||||
|
||||
$res = $mysqli->query("SHOW FULL TABLES");
|
||||
if (!$res) {
|
||||
error_log("MySQL Error: " . $mysqli->error);
|
||||
fclose($fh);
|
||||
error_log("MySQL Error (SHOW FULL TABLES): " . $mysqli->error);
|
||||
http_response_code(500);
|
||||
exit("Error retrieving tables.");
|
||||
}
|
||||
while ($row = $res->fetch_array(MYSQLI_NUM)) {
|
||||
$name = $row[0];
|
||||
$type = strtoupper($row[1] ?? '');
|
||||
if ($type === 'VIEW') {
|
||||
$views[] = $name;
|
||||
} else {
|
||||
$tables[] = $name;
|
||||
}
|
||||
}
|
||||
$res->close();
|
||||
|
||||
while ($row = $res->fetch_row()) {
|
||||
$tables[] = $row[0];
|
||||
// --- TABLES: structure and data ---
|
||||
foreach ($tables as $table) {
|
||||
$createRes = $mysqli->query("SHOW CREATE TABLE `{$mysqli->real_escape_string($table)}`");
|
||||
if (!$createRes) {
|
||||
error_log("MySQL Error (SHOW CREATE TABLE $table): " . $mysqli->error);
|
||||
// continue to next table
|
||||
continue;
|
||||
}
|
||||
$createRow = $createRes->fetch_assoc();
|
||||
$createSQL = array_values($createRow)[1] ?? '';
|
||||
$createRes->close();
|
||||
|
||||
fwrite_ln($fh, "-- ----------------------------");
|
||||
fwrite_ln($fh, "-- Table structure for `{$table}`");
|
||||
fwrite_ln($fh, "-- ----------------------------");
|
||||
fwrite_ln($fh, "DROP TABLE IF EXISTS `{$table}`;");
|
||||
fwrite_ln($fh, $createSQL . ";");
|
||||
fwrite_ln($fh, "");
|
||||
|
||||
// Dump data in a streaming fashion
|
||||
$dataRes = $mysqli->query("SELECT * FROM `{$mysqli->real_escape_string($table)}`", MYSQLI_USE_RESULT);
|
||||
if ($dataRes) {
|
||||
$wroteHeader = false;
|
||||
while ($row = $dataRes->fetch_assoc()) {
|
||||
if (!$wroteHeader) {
|
||||
fwrite_ln($fh, "-- Dumping data for table `{$table}`");
|
||||
$wroteHeader = true;
|
||||
}
|
||||
$cols = array_map(fn($c) => '`' . $mysqli->real_escape_string($c) . '`', array_keys($row));
|
||||
$vals = array_map(
|
||||
function ($v) use ($mysqli) {
|
||||
return is_null($v) ? "NULL" : "'" . $mysqli->real_escape_string($v) . "'";
|
||||
},
|
||||
array_values($row)
|
||||
);
|
||||
fwrite_ln($fh, "INSERT INTO `{$table}` (" . implode(", ", $cols) . ") VALUES (" . implode(", ", $vals) . ");");
|
||||
}
|
||||
$dataRes->close();
|
||||
if ($wroteHeader) fwrite_ln($fh, "");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$createRes = $mysqli->query("SHOW CREATE TABLE `$table`");
|
||||
if (!$createRes) {
|
||||
error_log("MySQL Error: " . $mysqli->error);
|
||||
// --- VIEWS ---
|
||||
foreach ($views as $view) {
|
||||
$escView = $mysqli->real_escape_string($view);
|
||||
$cRes = $mysqli->query("SHOW CREATE VIEW `{$escView}`");
|
||||
if ($cRes) {
|
||||
$row = $cRes->fetch_assoc();
|
||||
$createView = $row['Create View'] ?? '';
|
||||
$cRes->close();
|
||||
|
||||
fwrite_ln($fh, "-- ----------------------------");
|
||||
fwrite_ln($fh, "-- View structure for `{$view}`");
|
||||
fwrite_ln($fh, "-- ----------------------------");
|
||||
fwrite_ln($fh, "DROP VIEW IF EXISTS `{$view}`;");
|
||||
// Ensure statement ends with semicolon
|
||||
if (!str_ends_with($createView, ';')) $createView .= ';';
|
||||
fwrite_ln($fh, $createView);
|
||||
fwrite_ln($fh, "");
|
||||
}
|
||||
}
|
||||
|
||||
// --- TRIGGERS ---
|
||||
$tRes = $mysqli->query("SHOW TRIGGERS");
|
||||
if ($tRes) {
|
||||
while ($t = $tRes->fetch_assoc()) {
|
||||
$triggerName = $t['Trigger'];
|
||||
$escTrig = $mysqli->real_escape_string($triggerName);
|
||||
$crt = $mysqli->query("SHOW CREATE TRIGGER `{$escTrig}`");
|
||||
if ($crt) {
|
||||
$row = $crt->fetch_assoc();
|
||||
$createTrig = $row['SQL Original Statement'] ?? ($row['Create Trigger'] ?? '');
|
||||
$crt->close();
|
||||
|
||||
fwrite_ln($fh, "-- ----------------------------");
|
||||
fwrite_ln($fh, "-- Trigger for `{$triggerName}`");
|
||||
fwrite_ln($fh, "-- ----------------------------");
|
||||
fwrite_ln($fh, "DROP TRIGGER IF EXISTS `{$triggerName}`;");
|
||||
if (!str_ends_with($createTrig, ';')) $createTrig .= ';';
|
||||
fwrite_ln($fh, $createTrig);
|
||||
fwrite_ln($fh, "");
|
||||
}
|
||||
}
|
||||
$tRes->close();
|
||||
}
|
||||
|
||||
// Postamble
|
||||
fwrite_ln($fh, "SET FOREIGN_KEY_CHECKS = 1;");
|
||||
fwrite_ln($fh, "SET UNIQUE_CHECKS = 1;");
|
||||
fwrite_ln($fh, "COMMIT;");
|
||||
|
||||
fclose($fh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zip a folder to $zipFilePath, skipping symlinks and dot-entries.
|
||||
*/
|
||||
function zipFolderStrict(string $folderPath, string $zipFilePath): void {
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== TRUE) {
|
||||
error_log("Failed to open zip file: $zipFilePath");
|
||||
http_response_code(500);
|
||||
exit("Internal Server Error: Cannot open zip archive.");
|
||||
}
|
||||
|
||||
$folderReal = realpath($folderPath);
|
||||
if (!$folderReal || !is_dir($folderReal)) {
|
||||
// Create an empty archive if uploads folder doesn't exist yet
|
||||
$zip->close();
|
||||
return;
|
||||
}
|
||||
|
||||
$files = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($folderReal, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($files as $file) {
|
||||
/** @var SplFileInfo $file */
|
||||
if ($file->isDir()) continue;
|
||||
if ($file->isLink()) continue; // skip symlinks
|
||||
$filePath = $file->getRealPath();
|
||||
if ($filePath === false) continue;
|
||||
|
||||
// ensure path is inside the folder boundary
|
||||
if (strpos($filePath, $folderReal . DIRECTORY_SEPARATOR) !== 0 && $filePath !== $folderReal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$createRow = $createRes->fetch_assoc();
|
||||
$createSQL = array_values($createRow)[1];
|
||||
|
||||
$sqlContent .= "\n-- ----------------------------\n";
|
||||
$sqlContent .= "-- Table structure for `$table`\n";
|
||||
$sqlContent .= "-- ----------------------------\n";
|
||||
$sqlContent .= "DROP TABLE IF EXISTS `$table`;\n";
|
||||
$sqlContent .= $createSQL . ";\n\n";
|
||||
|
||||
$dataRes = $mysqli->query("SELECT * FROM `$table`");
|
||||
if ($dataRes && $dataRes->num_rows > 0) {
|
||||
$sqlContent .= "-- Dumping data for table `$table`\n";
|
||||
while ($row = $dataRes->fetch_assoc()) {
|
||||
$columns = array_map(fn($col) => '`' . $mysqli->real_escape_string($col) . '`', array_keys($row));
|
||||
$values = array_map(function ($val) use ($mysqli) {
|
||||
return is_null($val) ? "NULL" : "'" . $mysqli->real_escape_string($val) . "'";
|
||||
}, array_values($row));
|
||||
$sqlContent .= "INSERT INTO `$table` (" . implode(", ", $columns) . ") VALUES (" . implode(", ", $values) . ");\n";
|
||||
}
|
||||
$sqlContent .= "\n";
|
||||
}
|
||||
$relativePath = substr($filePath, strlen($folderReal) + 1);
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
}
|
||||
|
||||
$sqlContent .= "SET foreign_key_checks = 1;\n";
|
||||
file_put_contents($sqlFile, $sqlContent);
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
// === 4. Zip the uploads folder
|
||||
$zipFolder("../uploads", $uploadsZip);
|
||||
if (isset($_GET['download_backup'])) {
|
||||
|
||||
// === 5. Create version.txt
|
||||
$commitHash = trim(shell_exec('git log -1 --format=%H')) ?: 'N/A';
|
||||
$gitBranch = trim(shell_exec('git rev-parse --abbrev-ref HEAD')) ?: 'N/A';
|
||||
validateCSRFToken($_GET['csrf_token']);
|
||||
|
||||
$versionContent = "ITFlow Backup Metadata\n";
|
||||
$timestamp = date('YmdHis');
|
||||
$baseName = "itflow_{$timestamp}";
|
||||
$downloadName = $baseName . ".zip";
|
||||
|
||||
// === Scoped cleanup of temp files ===
|
||||
$cleanupFiles = [];
|
||||
$registerTempFileForCleanup = function ($file) use (&$cleanupFiles) {
|
||||
$cleanupFiles[] = $file;
|
||||
};
|
||||
register_shutdown_function(function () use (&$cleanupFiles) {
|
||||
foreach ($cleanupFiles as $file) {
|
||||
if (is_file($file)) { @unlink($file); }
|
||||
}
|
||||
});
|
||||
|
||||
// === Create temp files ===
|
||||
$sqlFile = tempnam(sys_get_temp_dir(), $baseName . "_sql_");
|
||||
$uploadsZip = tempnam(sys_get_temp_dir(), $baseName . "_uploads_");
|
||||
$versionFile = tempnam(sys_get_temp_dir(), $baseName . "_version_");
|
||||
$finalZip = tempnam(sys_get_temp_dir(), $baseName . "_backup_");
|
||||
|
||||
foreach ([$sqlFile, $uploadsZip, $versionFile, $finalZip] as $f) {
|
||||
$registerTempFileForCleanup($f);
|
||||
@chmod($f, 0600);
|
||||
}
|
||||
|
||||
// === Generate SQL Dump (streaming) ===
|
||||
dump_database_streaming($mysqli, $sqlFile);
|
||||
|
||||
// === Zip the uploads folder (strict) ===
|
||||
zipFolderStrict("../uploads", $uploadsZip);
|
||||
|
||||
// === Gather metadata & checksums ===
|
||||
$commitHash = (function_exists('shell_exec') ? trim(shell_exec('git log -1 --format=%H 2>/dev/null')) : '') ?: 'N/A';
|
||||
$gitBranch = (function_exists('shell_exec') ? trim(shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null')) : '') ?: 'N/A';
|
||||
|
||||
$dbSha = hash_file('sha256', $sqlFile) ?: 'N/A';
|
||||
$upSha = hash_file('sha256', $uploadsZip) ?: 'N/A';
|
||||
|
||||
$versionContent = "ITFlow Backup Metadata\n";
|
||||
$versionContent .= "-----------------------------\n";
|
||||
$versionContent .= "Generated: " . date('Y-m-d H:i:s') . "\n";
|
||||
$versionContent .= "Backup File: " . basename($finalZip) . "\n";
|
||||
$versionContent .= "Generated By: $session_name\n";
|
||||
$versionContent .= "Backup File: " . $downloadName . "\n";
|
||||
$versionContent .= "Generated By: " . ($session_name ?? 'Unknown User') . "\n";
|
||||
$versionContent .= "Host: " . gethostname() . "\n";
|
||||
$versionContent .= "Git Branch: $gitBranch\n";
|
||||
$versionContent .= "Git Commit: $commitHash\n";
|
||||
$versionContent .= "ITFlow Version: " . (defined('APP_VERSION') ? APP_VERSION : 'Unknown') . "\n";
|
||||
$versionContent .= "Database Version: " . (defined('CURRENT_DATABASE_VERSION') ? CURRENT_DATABASE_VERSION : 'Unknown') . "\n";
|
||||
$versionContent .= "Checksum (SHA256): \n";
|
||||
$versionContent .= "Checksums (SHA256):\n";
|
||||
$versionContent .= " db.sql: $dbSha\n";
|
||||
$versionContent .= " uploads.zip: $upSha\n";
|
||||
|
||||
file_put_contents($versionFile, $versionContent);
|
||||
@chmod($versionFile, 0600);
|
||||
|
||||
// === 6. Build final ZIP
|
||||
// === Build final ZIP ===
|
||||
$final = new ZipArchive();
|
||||
if ($final->open($finalZip, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== TRUE) {
|
||||
error_log("Failed to create final zip: $finalZip");
|
||||
http_response_code(500);
|
||||
exit("Internal Server Error: Unable to create backup archive.");
|
||||
}
|
||||
|
||||
$final->addFile($sqlFile, "db.sql");
|
||||
$final->addFile($uploadsZip, "uploads.zip");
|
||||
$final->addFile($versionFile, "version.txt");
|
||||
$final->close();
|
||||
|
||||
chmod($finalZip, 0600);
|
||||
@chmod($finalZip, 0600);
|
||||
|
||||
$checksum = hash_file('sha256', $finalZip);
|
||||
file_put_contents($versionFile, $versionContent . "$checksum\n");
|
||||
|
||||
// === 7. Serve final ZIP
|
||||
// === Serve final ZIP with a stable filename ===
|
||||
header('Content-Type: application/zip');
|
||||
header('Content-Disposition: attachment; filename="' . basename($finalZip) . '"');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Content-Disposition: attachment; filename="' . $downloadName . '"');
|
||||
header('Content-Length: ' . filesize($finalZip));
|
||||
header('Pragma: public');
|
||||
header('Expires: 0');
|
||||
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
|
||||
header('Content-Transfer-Encoding: binary');
|
||||
|
||||
// Push file
|
||||
flush();
|
||||
$fp = fopen($finalZip, 'rb');
|
||||
fpassthru($fp);
|
||||
fclose($fp);
|
||||
|
||||
logAction("System", "Backup Download", "$session_name downloaded full backup.");
|
||||
// Log + UX
|
||||
logAction("System", "Backup Download", ($session_name ?? 'Unknown User') . " downloaded full backup.");
|
||||
flash_alert("Full backup downloaded.");
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
if (isset($_POST['backup_master_key'])) {
|
||||
|
||||
validateCSRFToken($_POST['csrf_token']);
|
||||
|
|
|
|||
283
setup/index.php
283
setup/index.php
|
|
@ -5,7 +5,8 @@ if (file_exists("../config.php")) {
|
|||
|
||||
}
|
||||
|
||||
include "../functions.php";
|
||||
include "../functions.php"; // Global Functions
|
||||
include "setup_functions.php"; // Setup Only Functions
|
||||
include "../includes/database_version.php";
|
||||
|
||||
if (!isset($config_enable_setup)) {
|
||||
|
|
@ -127,134 +128,220 @@ if (isset($_POST['add_database'])) {
|
|||
|
||||
if (isset($_POST['restore'])) {
|
||||
|
||||
// === 1. Validate uploaded file ===
|
||||
// --- CSRF check (add a token to the form; see form snippet below) ---
|
||||
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
|
||||
http_response_code(403);
|
||||
exit("Invalid CSRF token.");
|
||||
}
|
||||
|
||||
// --- Basic env guards for long operations ---
|
||||
@set_time_limit(0);
|
||||
if (function_exists('ini_set')) { @ini_set('memory_limit', '1024M'); }
|
||||
|
||||
// --- 1) Validate uploaded file ---
|
||||
if (!isset($_FILES['backup_zip']) || $_FILES['backup_zip']['error'] !== UPLOAD_ERR_OK) {
|
||||
die("No backup file uploaded or upload failed.");
|
||||
}
|
||||
|
||||
$file = $_FILES['backup_zip'];
|
||||
$fileExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
if ($fileExt !== "zip") {
|
||||
|
||||
// Size limit (e.g., 4 GB)
|
||||
if ($file['size'] > 4 * 1024 * 1024 * 1024) {
|
||||
die("Backup archive is too large.");
|
||||
}
|
||||
|
||||
// MIME check
|
||||
$fi = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $fi->file($file['tmp_name']);
|
||||
if ($mime !== 'application/zip' && $mime !== 'application/x-zip-compressed') {
|
||||
die("Invalid archive type; only .zip is supported.");
|
||||
}
|
||||
|
||||
// Extension check (defense in depth)
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
if ($ext !== 'zip') {
|
||||
die("Only .zip files are allowed.");
|
||||
}
|
||||
|
||||
// === 2. Move to secure temp location ===
|
||||
$tempZip = tempnam(sys_get_temp_dir(), "restore_");
|
||||
// --- 2) Move to secure temp location ---
|
||||
$timestamp = date('YmdHis');
|
||||
$tempZip = tempnam(sys_get_temp_dir(), "restore_{$timestamp}_");
|
||||
if (!move_uploaded_file($file["tmp_name"], $tempZip)) {
|
||||
die("Failed to save uploaded backup file.");
|
||||
}
|
||||
@chmod($tempZip, 0600);
|
||||
|
||||
// --- 3) Extract safely to unique temp dir ---
|
||||
$tempDir = sys_get_temp_dir() . "/restore_temp_" . bin2hex(random_bytes(6));
|
||||
if (!mkdir($tempDir, 0700, true)) {
|
||||
@unlink($tempZip);
|
||||
die("Failed to create temp directory.");
|
||||
}
|
||||
|
||||
$zip = new ZipArchive;
|
||||
if ($zip->open($tempZip) !== TRUE) {
|
||||
unlink($tempZip);
|
||||
@unlink($tempZip);
|
||||
deleteDir($tempDir);
|
||||
die("Failed to open backup zip file.");
|
||||
}
|
||||
|
||||
// === 3. Zip-slip protection and extract to unique dir ===
|
||||
$tempDir = sys_get_temp_dir() . "/restore_temp_" . uniqid();
|
||||
mkdir($tempDir, 0700, true);
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
if (strpos($stat['name'], '..') !== false) {
|
||||
$zip->close();
|
||||
unlink($tempZip);
|
||||
die("Invalid file path in ZIP.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!$zip->extractTo($tempDir)) {
|
||||
try {
|
||||
safeExtractZip($zip, $tempDir);
|
||||
} catch (Throwable $e) {
|
||||
$zip->close();
|
||||
unlink($tempZip);
|
||||
die("Failed to extract backup contents.");
|
||||
@unlink($tempZip);
|
||||
deleteDir($tempDir);
|
||||
die("Invalid backup archive: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'));
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
unlink($tempZip);
|
||||
@unlink($tempZip);
|
||||
|
||||
// === 4. Restore SQL ===
|
||||
$sqlPath = "$tempDir/db.sql";
|
||||
if (file_exists($sqlPath)) {
|
||||
mysqli_query($mysqli, "SET foreign_key_checks = 0");
|
||||
$tables = mysqli_query($mysqli, "SHOW TABLES");
|
||||
while ($row = mysqli_fetch_array($tables)) {
|
||||
mysqli_query($mysqli, "DROP TABLE IF EXISTS `" . $row[0] . "`");
|
||||
}
|
||||
mysqli_query($mysqli, "SET foreign_key_checks = 1");
|
||||
// Paths inside extracted archive
|
||||
$sqlPath = $tempDir . "/db.sql";
|
||||
$uploadsZip = $tempDir . "/uploads.zip";
|
||||
$versionTxt = $tempDir . "/version.txt";
|
||||
|
||||
// Use env var to avoid exposing password
|
||||
putenv("MYSQL_PWD=$dbpassword");
|
||||
$command = sprintf(
|
||||
'mysql -h%s -u%s %s < %s',
|
||||
escapeshellarg($dbhost),
|
||||
escapeshellarg($dbusername),
|
||||
escapeshellarg($database),
|
||||
escapeshellarg($sqlPath)
|
||||
);
|
||||
|
||||
exec($command, $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
deleteDir($tempDir);
|
||||
die("SQL import failed. Error code: $returnCode");
|
||||
}
|
||||
} else {
|
||||
if (!is_file($sqlPath) || !is_readable($sqlPath)) {
|
||||
deleteDir($tempDir);
|
||||
die("Missing db.sql in the backup archive.");
|
||||
}
|
||||
|
||||
// === 5. Restore uploads directory ===
|
||||
$uploadDir = __DIR__ . "/../uploads/";
|
||||
$uploadsZip = "$tempDir/uploads.zip";
|
||||
|
||||
if (file_exists($uploadsZip)) {
|
||||
$uploads = new ZipArchive;
|
||||
if ($uploads->open($uploadsZip) === TRUE) {
|
||||
// Clean existing uploads
|
||||
foreach (new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($uploadDir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
) as $item) {
|
||||
$item->isDir() ? rmdir($item) : unlink($item);
|
||||
}
|
||||
|
||||
$uploads->extractTo($uploadDir);
|
||||
$uploads->close();
|
||||
} else {
|
||||
deleteDir($tempDir);
|
||||
die("Failed to open uploads.zip in backup.");
|
||||
}
|
||||
} else {
|
||||
if (!is_file($uploadsZip) || !is_readable($uploadsZip)) {
|
||||
deleteDir($tempDir);
|
||||
die("Missing uploads.zip in the backup archive.");
|
||||
}
|
||||
|
||||
// === 6. Read version.txt (optional display/logging) ===
|
||||
$versionTxt = "$tempDir/version.txt";
|
||||
if (file_exists($versionTxt)) {
|
||||
$versionInfo = file_get_contents($versionTxt);
|
||||
logAction("Backup Restore", "Version Info", $versionInfo);
|
||||
// --- 4) Optional: check version compatibility ---
|
||||
if (defined('LATEST_DATABASE_VERSION') && is_file($versionTxt)) {
|
||||
$txt = @file_get_contents($versionTxt) ?: '';
|
||||
// Try to find line "Database Version: X"
|
||||
if (preg_match('/^Database Version:\s*(.+)$/mi', $txt, $m)) {
|
||||
$backupVersion = trim($m[1]);
|
||||
$running = LATEST_DATABASE_VERSION;
|
||||
// If backup schema is newer, abort with instruction
|
||||
if (version_compare($backupVersion, $running, '>')) {
|
||||
deleteDir($tempDir);
|
||||
die("Backup schema ($backupVersion) is newer than this app ($running). Please upgrade ITFlow first, then retry restore.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === 7. Clean up temp dir ===
|
||||
function deleteDir($dir) {
|
||||
if (!is_dir($dir)) return;
|
||||
$items = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($items as $item) {
|
||||
$item->isDir() ? rmdir($item) : unlink($item);
|
||||
// --- 5) Restore SQL (drop + import) ---
|
||||
// Drop all tables
|
||||
mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 0");
|
||||
$tables = mysqli_query($mysqli, "SHOW TABLES");
|
||||
if ($tables) {
|
||||
while ($row = mysqli_fetch_array($tables)) {
|
||||
$tbl = $row[0];
|
||||
mysqli_query($mysqli, "DROP TABLE IF EXISTS `".$mysqli->real_escape_string($tbl)."`");
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 1");
|
||||
|
||||
try {
|
||||
importSqlFile($mysqli, $sqlPath);
|
||||
} catch (Throwable $e) {
|
||||
deleteDir($tempDir);
|
||||
die("SQL import failed: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'));
|
||||
}
|
||||
|
||||
// --- 6) Restore uploads via staging + atomic swap ---
|
||||
$appRoot = realpath(__DIR__ . "/..");
|
||||
$uploadDir = realpath($appRoot . "/uploads");
|
||||
if ($uploadDir === false) {
|
||||
// uploads might not exist yet
|
||||
$uploadDir = $appRoot . "/uploads";
|
||||
if (!mkdir($uploadDir, 0750, true)) {
|
||||
deleteDir($tempDir);
|
||||
die("Failed to create uploads directory.");
|
||||
}
|
||||
$uploadDir = realpath($uploadDir);
|
||||
}
|
||||
|
||||
if ($uploadDir === false || str_starts_with($uploadDir, $appRoot) === false) {
|
||||
deleteDir($tempDir);
|
||||
die("Uploads directory path invalid.");
|
||||
}
|
||||
|
||||
$staging = $appRoot . "/uploads_restoring_" . bin2hex(random_bytes(4));
|
||||
if (!mkdir($staging, 0700, true)) {
|
||||
deleteDir($tempDir);
|
||||
die("Failed to create staging directory.");
|
||||
}
|
||||
|
||||
$uz = new ZipArchive;
|
||||
if ($uz->open($uploadsZip) !== TRUE) {
|
||||
deleteDir($staging);
|
||||
deleteDir($tempDir);
|
||||
die("Failed to open uploads.zip in backup.");
|
||||
}
|
||||
|
||||
// IMPORTANT: staging dir should be empty here (as in your existing flow)
|
||||
$result = extractUploadsZipWithValidationReport($uz, $staging, [
|
||||
'max_file_bytes' => 200 * 1024 * 1024, // adjust per-file size cap
|
||||
'blocked_exts' => [
|
||||
'php','php3','php4','php5','php7','php8','phtml','phar',
|
||||
'cgi','pl','sh','bash','zsh','exe','dll','bat','cmd','com',
|
||||
'ps1','vbs','vb','jar','jsp','asp','aspx','so','dylib','bin'
|
||||
],
|
||||
]);
|
||||
$uz->close();
|
||||
|
||||
if (!$result['ok']) {
|
||||
// Build a user-friendly report
|
||||
$lines = ["Unsafe file(s) detected in uploads.zip:"];
|
||||
foreach ($result['issues'] as $issue) {
|
||||
$p = htmlspecialchars($issue['path'], ENT_QUOTES, 'UTF-8');
|
||||
$r = htmlspecialchars($issue['reason'], ENT_QUOTES, 'UTF-8');
|
||||
$lines[] = "• {$p} — {$r}";
|
||||
}
|
||||
|
||||
// Clean staging and temp and show the report
|
||||
deleteDir($staging);
|
||||
deleteDir($tempDir);
|
||||
|
||||
$_SESSION['alert_message'] = nl2br(implode("\n", $lines));
|
||||
header("Location: ?restore");
|
||||
exit;
|
||||
|
||||
}
|
||||
|
||||
// Rotate old uploads out, promote staging in
|
||||
$backupOld = $appRoot . "/uploads_old_" . time();
|
||||
if (!rename($uploadDir, $backupOld)) {
|
||||
deleteDir($staging);
|
||||
deleteDir($tempDir);
|
||||
die("Failed to rotate old uploads.");
|
||||
}
|
||||
if (!rename($staging, $uploadDir)) {
|
||||
// try to revert
|
||||
@rename($backupOld, $uploadDir);
|
||||
deleteDir($tempDir);
|
||||
die("Failed to promote restored uploads.");
|
||||
}
|
||||
// Optional: clean old uploads now or keep briefly for rollback
|
||||
// deleteDir($backupOld);
|
||||
|
||||
// --- 7) Log version info (optional) ---
|
||||
if (is_file($versionTxt)) {
|
||||
$versionInfo = @file_get_contents($versionTxt);
|
||||
if ($versionInfo !== false) {
|
||||
logAction("Backup Restore", "Version Info", $versionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 8) Cleanup temp dir ---
|
||||
deleteDir($tempDir);
|
||||
|
||||
// === 8. Optional: finalize setup flag ===
|
||||
$myfile = fopen("../config.php", "a");
|
||||
fwrite($myfile, "\$config_enable_setup = 0;\n\n");
|
||||
fclose($myfile);
|
||||
// --- 9) Finalize setup flag (idempotent) ---
|
||||
try {
|
||||
setConfigFlag("../config.php", "config_enable_setup", 0);
|
||||
} catch (Throwable $e) {
|
||||
// Non-fatal; warn but continue to login
|
||||
$_SESSION['alert_message'] = "Backup restored, but failed to finalize setup flag in config.php: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8');
|
||||
header("Location: ../login.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
// === 9. Done ===
|
||||
// --- 10) Done ---
|
||||
$_SESSION['alert_message'] = "Full backup restored successfully.";
|
||||
header("Location: ../login.php");
|
||||
exit;
|
||||
|
|
@ -1109,9 +1196,15 @@ if (isset($_POST['add_telemetry'])) {
|
|||
<h3 class="card-title"><i class="fas fa-fw fa-database mr-2"></i>Restore from Backup</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<?php
|
||||
// generate CSRF token for this form
|
||||
if (empty($_SESSION['csrf'])) { $_SESSION['csrf'] = bin2hex(random_bytes(32)); }
|
||||
?>
|
||||
<form method="post" enctype="multipart/form-data" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="<?php echo htmlspecialchars($_SESSION['csrf']); ?>">
|
||||
<label>Restore ITFlow Backup (.zip)</label>
|
||||
<input type="file" name="backup_zip" accept=".zip" required>
|
||||
<p class="text-muted mt-2 mb-0"><small>Large restores may take several minutes. Do not close this page.</small></p>
|
||||
<hr>
|
||||
<button type="submit" name="restore" class="btn btn-primary text-bold">
|
||||
Restore Backup<i class="fas fa-fw fa-upload ml-2"></i>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,347 @@
|
|||
<?php
|
||||
// --- Helpers for restore ---
|
||||
|
||||
/** Delete a directory recursively (safe, no symlinks followed). */
|
||||
function deleteDir(string $dir): void {
|
||||
if (!is_dir($dir)) return;
|
||||
$it = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($it as $item) {
|
||||
if ($item->isDir()) { @rmdir($item->getPathname()); }
|
||||
else { @unlink($item->getPathname()); }
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
/** Import a SQL file via mysqli, supporting custom DELIMITER and multi statements. */
|
||||
function importSqlFile(mysqli $mysqli, string $path): void {
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
throw new RuntimeException("SQL file not found or unreadable: $path");
|
||||
}
|
||||
$fh = fopen($path, 'r');
|
||||
if (!$fh) throw new RuntimeException("Failed to open SQL file");
|
||||
|
||||
$delimiter = ';';
|
||||
$statement = '';
|
||||
|
||||
while (($line = fgets($fh)) !== false) {
|
||||
$trim = trim($line);
|
||||
|
||||
// skip comments and empty lines
|
||||
if ($trim === '' || str_starts_with($trim, '--') || str_starts_with($trim, '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// handle DELIMITER changes
|
||||
if (preg_match('/^DELIMITER\s+(.+)$/i', $trim, $m)) {
|
||||
$delimiter = $m[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
$statement .= $line;
|
||||
|
||||
// end of statement?
|
||||
if (substr(rtrim($statement), -strlen($delimiter)) === $delimiter) {
|
||||
$sql = substr($statement, 0, -strlen($delimiter));
|
||||
if ($mysqli->multi_query($sql) === false) {
|
||||
fclose($fh);
|
||||
throw new RuntimeException("SQL error: ".$mysqli->error);
|
||||
}
|
||||
// flush any result sets
|
||||
while ($mysqli->more_results() && $mysqli->next_result()) { /* discard */ }
|
||||
$statement = '';
|
||||
}
|
||||
}
|
||||
fclose($fh);
|
||||
}
|
||||
|
||||
/** Extract a zip safely to $destDir. Blocks absolute paths, drive letters, and symlinks. */
|
||||
function safeExtractZip(ZipArchive $zip, string $destDir): void {
|
||||
$rootReal = realpath($destDir);
|
||||
if ($rootReal === false) {
|
||||
if (!mkdir($destDir, 0700, true)) {
|
||||
throw new RuntimeException("Failed to create temp dir");
|
||||
}
|
||||
$rootReal = realpath($destDir);
|
||||
}
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
|
||||
// Reject absolute or drive-lettered paths
|
||||
if (preg_match('#^(?:/|\\\\|[a-zA-Z]:[\\\\/])#', $name)) {
|
||||
throw new RuntimeException("Invalid absolute path in zip: $name");
|
||||
}
|
||||
|
||||
// Normalize components and skip traversal attempts
|
||||
$target = $rootReal . DIRECTORY_SEPARATOR . $name;
|
||||
$targetDir = dirname($target);
|
||||
if (!is_dir($targetDir) && !mkdir($targetDir, 0700, true)) {
|
||||
throw new RuntimeException("Failed to create $targetDir");
|
||||
}
|
||||
|
||||
// Directories end with '/'
|
||||
$isDir = str_ends_with($name, '/');
|
||||
|
||||
// Read entry
|
||||
$fp = $zip->getStream($name);
|
||||
if ($fp === false) {
|
||||
if ($isDir) continue;
|
||||
throw new RuntimeException("Failed to read $name from zip");
|
||||
}
|
||||
|
||||
if ($isDir) {
|
||||
if (!is_dir($target) && !mkdir($target, 0700, true)) {
|
||||
fclose($fp);
|
||||
throw new RuntimeException("Failed to mkdir $target");
|
||||
}
|
||||
fclose($fp);
|
||||
} else {
|
||||
$out = fopen($target, 'wb');
|
||||
if (!$out) { fclose($fp); throw new RuntimeException("Failed to create $target"); }
|
||||
stream_copy_to_stream($fp, $out);
|
||||
fclose($fp);
|
||||
fclose($out);
|
||||
|
||||
// Final boundary check
|
||||
$real = realpath($target);
|
||||
if ($real === false || str_starts_with($real, $rootReal) === false) {
|
||||
@unlink($target);
|
||||
throw new RuntimeException("Path traversal detected for $name");
|
||||
}
|
||||
|
||||
// Disallow symlinks (in case zip contained one)
|
||||
if (is_link($real)) {
|
||||
@unlink($real);
|
||||
throw new RuntimeException("Symlink detected in archive: $name");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Idempotently set/append a PHP config flag like $config_enable_setup = 0; */
|
||||
function setConfigFlag(string $file, string $key, $value): void {
|
||||
$cfg = @file_get_contents($file);
|
||||
if ($cfg === false) throw new RuntimeException("Cannot read $file");
|
||||
$pattern = '/^\s*\$'.preg_quote($key, '/').'\s*=\s*.*?;\s*$/m';
|
||||
$line = '$'.$key.' = '.(is_bool($value)? ($value?'true':'false') : var_export($value,true)).";\n";
|
||||
if (preg_match($pattern, $cfg)) {
|
||||
$cfg = preg_replace($pattern, $line, $cfg);
|
||||
} else {
|
||||
$cfg .= "\n".$line;
|
||||
}
|
||||
if (file_put_contents($file, $cfg, LOCK_EX) === false) {
|
||||
throw new RuntimeException("Failed to update $file");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if a filename has a disallowed extension or looks like a double-extension trick.
|
||||
*/
|
||||
function hasDangerousExtension(string $name, array $blockedExts): bool {
|
||||
$lower = strtolower($name);
|
||||
// Quick reject on hidden PHP or dotfiles that may alter server behavior
|
||||
if (preg_match('/(^|\/)\.(htaccess|user\.ini|env)$/i', $lower)) return true;
|
||||
|
||||
// Pull last extension
|
||||
$ext = strtolower(pathinfo($lower, PATHINFO_EXTENSION));
|
||||
if (in_array($ext, $blockedExts, true)) return true;
|
||||
|
||||
// Double extension (e.g., .jpg.php, .png.sh)
|
||||
if (preg_match('/\.(?:[a-z0-9]{1,5})\.(php[0-9]?|phtml|phar|cgi|pl|sh|exe|dll|bat|cmd|com|ps1|vb|vbs|jar|jsp|asp|aspx)$/i', $lower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic content scan for executable code. Reads head/tail of file.
|
||||
*/
|
||||
function contentLooksExecutable(string $tmpPath): bool {
|
||||
// Use finfo to detect executable/script mimetypes
|
||||
$fi = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $fi->file($tmpPath) ?: '';
|
||||
|
||||
// Quick MIME-based blocks (don’t rely solely on this)
|
||||
if (preg_match('#^(application/x-(php|elf|sharedlib|mach-o)|text/x-(php|script|shell))#i', $mime)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read first/last 4KB for signature checks without loading whole file
|
||||
$fp = @fopen($tmpPath, 'rb');
|
||||
if (!$fp) return false;
|
||||
|
||||
$head = fread($fp, 4096) ?: '';
|
||||
// Seek last 4KB if file >4KB
|
||||
$tail = '';
|
||||
$stat = fstat($fp);
|
||||
if ($stat && $stat['size'] > 4096) {
|
||||
fseek($fp, -4096, SEEK_END);
|
||||
$tail = fread($fp, 4096) ?: '';
|
||||
}
|
||||
fclose($fp);
|
||||
|
||||
$blob = $head . $tail;
|
||||
|
||||
// Block common code markers / execution hints
|
||||
$markers = [
|
||||
'<?php', '<?=',
|
||||
'#!/usr/bin/env php', '#!/usr/bin/php',
|
||||
'#!/bin/bash', '#!/bin/sh', '#!/usr/bin/env bash',
|
||||
'eval(', 'assert(', 'base64_decode(',
|
||||
'shell_exec(', 'proc_open(', 'popen(', 'system(', 'passthru(',
|
||||
];
|
||||
foreach ($markers as $m) {
|
||||
if (stripos($blob, $m) !== false) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract uploads.zip to $destDir with validation & reporting.
|
||||
* - Validates each entry (boundary, extension, MIME/signatures, size)
|
||||
* - Collects all issues instead of failing fast
|
||||
* - If any issues are found, NOTHING is extracted and an array of problems is returned
|
||||
*
|
||||
* @return array{ok: bool, issues?: array<int, array{path: string, reason: string}>}
|
||||
*/
|
||||
function extractUploadsZipWithValidationReport(ZipArchive $zip, string $destDir, array $options = []): array {
|
||||
$maxFileBytes = $options['max_file_bytes'] ?? (200 * 1024 * 1024); // 200MB
|
||||
$blockedExts = $options['blocked_exts'] ?? [
|
||||
'php','php3','php4','php5','php7','php8','phtml','phar',
|
||||
'cgi','pl','sh','bash','zsh','exe','dll','bat','cmd','com',
|
||||
'ps1','vbs','vb','jar','jsp','asp','aspx','so','dylib','bin'
|
||||
];
|
||||
|
||||
$issues = [];
|
||||
$staging = $destDir; // caller gives us a staging dir (empty)
|
||||
$rootReal = realpath($staging);
|
||||
if ($rootReal === false) {
|
||||
if (!mkdir($staging, 0700, true)) {
|
||||
return ['ok' => false, 'issues' => [['path' => '(staging)', 'reason' => 'Failed to create staging directory']]];
|
||||
}
|
||||
$rootReal = realpath($staging);
|
||||
}
|
||||
|
||||
// First pass: validate all entries and write candidates to temp files only
|
||||
// We keep a map of tmp files to final target paths; if any issue is found, we clean them and return
|
||||
$pending = []; // [ [ 'tmp' => '/tmp/..', 'target' => '/final/path', 'name' => 'zip/path' ], ... ]
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
|
||||
// Reject absolute/drive-lettered paths
|
||||
if (preg_match('#^(?:/|\\\\|[a-zA-Z]:[\\\\/])#', $name)) {
|
||||
$issues[] = ['path' => $name, 'reason' => 'Invalid absolute or drive path'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$isDir = str_ends_with($name, '/');
|
||||
if ($isDir) {
|
||||
// We’ll create directories in the commit phase if no issues were found
|
||||
continue;
|
||||
}
|
||||
|
||||
$stream = $zip->getStream($name);
|
||||
if ($stream === false) {
|
||||
$issues[] = ['path' => $name, 'reason' => 'Unable to read entry from ZIP'];
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1) Extension and double-extension checks
|
||||
if (hasDangerousExtension($name, $blockedExts)) {
|
||||
fclose($stream);
|
||||
$issues[] = ['path' => $name, 'reason' => 'Dangerous or disallowed file extension'];
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2) Stream into a temp file (size-capped) for MIME/signature checks
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'uplscan_');
|
||||
if ($tmp === false) { fclose($stream); $issues[] = ['path' => $name, 'reason' => 'Failed to create temp file']; continue; }
|
||||
|
||||
$out = fopen($tmp, 'wb');
|
||||
if (!$out) { fclose($stream); @unlink($tmp); $issues[] = ['path' => $name, 'reason' => 'Failed to write temp file']; continue; }
|
||||
|
||||
$bytes = 0;
|
||||
$err = null;
|
||||
while (!feof($stream)) {
|
||||
$chunk = fread($stream, 1 << 15);
|
||||
if ($chunk === false) { $err = 'Read error while extracting'; break; }
|
||||
$bytes += strlen($chunk);
|
||||
if ($bytes > $maxFileBytes) { $err = 'File exceeds per-file size limit'; break; }
|
||||
if (fwrite($out, $chunk) === false) { $err = 'Write error while buffering'; break; }
|
||||
}
|
||||
fclose($stream);
|
||||
fclose($out);
|
||||
|
||||
if ($err !== null) {
|
||||
@unlink($tmp);
|
||||
$issues[] = ['path' => $name, 'reason' => $err];
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3) MIME + signature checks
|
||||
if (contentLooksExecutable($tmp)) {
|
||||
@unlink($tmp);
|
||||
$issues[] = ['path' => $name, 'reason' => 'Executable/script content detected'];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Record as candidate for commit
|
||||
$target = $rootReal . DIRECTORY_SEPARATOR . $name;
|
||||
$pending[] = ['tmp' => $tmp, 'target' => $target, 'name' => $name];
|
||||
}
|
||||
|
||||
// If any issues, cleanup temps and return report
|
||||
if (!empty($issues)) {
|
||||
foreach ($pending as $p) { @unlink($p['tmp']); }
|
||||
return ['ok' => false, 'issues' => $issues];
|
||||
}
|
||||
|
||||
// Commit phase: create directories and move files into place
|
||||
foreach ($pending as $p) {
|
||||
$finalDir = dirname($p['target']);
|
||||
if (!is_dir($finalDir) && !mkdir($finalDir, 0700, true)) {
|
||||
// Rollback partially moved files
|
||||
foreach ($pending as $r) { @unlink($r['tmp']); }
|
||||
return ['ok' => false, 'issues' => [['path' => $p['name'], 'reason' => 'Failed to create destination directory']]];
|
||||
}
|
||||
|
||||
// Boundary check again
|
||||
$realFinalDir = realpath($finalDir);
|
||||
if ($realFinalDir === false || strpos($realFinalDir, $rootReal) !== 0) {
|
||||
foreach ($pending as $r) { @unlink($r['tmp']); }
|
||||
return ['ok' => false, 'issues' => [['path' => $p['name'], 'reason' => 'Path traversal detected at commit phase']]];
|
||||
}
|
||||
|
||||
if (!rename($p['tmp'], $p['target'])) {
|
||||
if (!copy($p['tmp'], $p['target'])) {
|
||||
@unlink($p['tmp']);
|
||||
// Cleanup remaining temps
|
||||
foreach ($pending as $r) { @unlink($r['tmp']); }
|
||||
return ['ok' => false, 'issues' => [['path' => $p['name'], 'reason' => 'Failed to place file in destination']]];
|
||||
}
|
||||
@unlink($p['tmp']);
|
||||
}
|
||||
|
||||
// Permissions & final checks
|
||||
@chmod($p['target'], 0640);
|
||||
$real = realpath($p['target']);
|
||||
if ($real === false || strpos($real, $rootReal) !== 0) {
|
||||
@unlink($p['target']);
|
||||
foreach ($pending as $r) { @unlink($r['tmp']); }
|
||||
return ['ok' => false, 'issues' => [['path' => $p['name'], 'reason' => 'Boundary check failed after write']]];
|
||||
}
|
||||
if (is_link($real)) {
|
||||
@unlink($real);
|
||||
foreach ($pending as $r) { @unlink($r['tmp']); }
|
||||
return ['ok' => false, 'issues' => [['path' => $p['name'], 'reason' => 'Symlink detected in destination']]];
|
||||
}
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
php_flag engine off
|
||||
RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .php8
|
||||
RemoveType application/x-httpd-php
|
||||
<FilesMatch "\.(php|phtml|php[0-9])$">
|
||||
Deny from all
|
||||
</FilesMatch>
|
||||
Options -ExecCGI
|
||||
AddHandler cgi-script .cgi .pl .sh .bash .zsh
|
||||
<FilesMatch "\.(cgi|pl|sh|bash|zsh)$">
|
||||
Deny from all
|
||||
</FilesMatch>
|
||||
Loading…
Reference in New Issue