stage #2
@@ -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>
|
||||
|
||||
347
setup/setup_functions.php
Normal file
347
setup/setup_functions.php
Normal file
@@ -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];
|
||||
}
|
||||
11
uploads/.htaccess
Normal file
11
uploads/.htaccess
Normal file
@@ -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>
|
||||
Reference in New Issue
Block a user