fix: Remaining audit findings — OOM, security, error handling (#81) #85
@@ -360,16 +360,12 @@ class AkeebaImporter
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Try JSON
|
||||
// Parse as JSON only — unserialize is an object injection risk
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
// Try unserialize (older Akeeba versions)
|
||||
$data = @unserialize($raw);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return $result;
|
||||
}
|
||||
// Older Akeeba versions used serialized PHP — skip rather than risk object injection
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Extract directory exclusions
|
||||
|
||||
@@ -85,6 +85,7 @@ class BackupEngine
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$tag = $resolver->getTag();
|
||||
$archiveFormat = $profile->archive_format ?? 'zip';
|
||||
$archiveName = '';
|
||||
$archiver = $this->createArchiver($archiveFormat);
|
||||
$archiveExt = $archiver->getExtension();
|
||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
||||
@@ -130,12 +131,15 @@ class BackupEngine
|
||||
$tablesCount = 0;
|
||||
|
||||
// Step 1: Database dump (unless files-only)
|
||||
// Streams to a temp file to avoid loading the entire dump into RAM
|
||||
$sqlTempFile = '';
|
||||
|
||||
if ($profile->backup_type !== 'files') {
|
||||
$this->log('Starting database dump...');
|
||||
$dumper = new DatabaseDumper($excludeTables);
|
||||
$sqlDump = $dumper->dump();
|
||||
$archiver->addFromString('database.sql', $sqlDump);
|
||||
$dbSize = strlen($sqlDump);
|
||||
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql';
|
||||
$dumper = new DatabaseDumper($excludeTables);
|
||||
$dbSize = $dumper->dumpToFile($sqlTempFile);
|
||||
$archiver->addFile($sqlTempFile, 'database.sql');
|
||||
$tablesCount = $dumper->getTablesCount();
|
||||
$this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes');
|
||||
}
|
||||
@@ -203,6 +207,11 @@ class BackupEngine
|
||||
|
||||
$archiver->close();
|
||||
|
||||
// Clean up temp SQL file (no longer needed after archive is closed)
|
||||
if (!empty($sqlTempFile) && is_file($sqlTempFile)) {
|
||||
@unlink($sqlTempFile);
|
||||
}
|
||||
|
||||
// Step 1.5: Apply AES-256 encryption (if configured)
|
||||
$encryptionPassword = $profile->encryption_password ?? '';
|
||||
|
||||
@@ -315,6 +324,17 @@ class BackupEngine
|
||||
} catch (\Throwable $e) {
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
// Clean up temp SQL file on failure
|
||||
if (!empty($sqlTempFile) && is_file($sqlTempFile)) {
|
||||
@unlink($sqlTempFile);
|
||||
}
|
||||
|
||||
// If encryption was intended and failed, remove the plaintext archive
|
||||
if (!empty($encryptionPassword) && !empty($archivePath) && is_file($archivePath)) {
|
||||
@unlink($archivePath);
|
||||
$this->log('Plaintext archive removed after encryption failure');
|
||||
}
|
||||
|
||||
$update = (object) [
|
||||
'id' => $recordId,
|
||||
'status' => 'fail',
|
||||
@@ -402,7 +422,7 @@ class BackupEngine
|
||||
return match ($format) {
|
||||
'zip' => new ZipArchiver(),
|
||||
'tar.gz' => new TarGzArchiver(),
|
||||
default => new ZipArchiver(),
|
||||
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,138 @@ class DatabaseDumper
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump all database tables directly to a file, streaming row by row.
|
||||
* Avoids loading the entire dump into RAM.
|
||||
*
|
||||
* @param string $filePath Absolute path to write the SQL file
|
||||
*
|
||||
* @return int Size of the dump file in bytes
|
||||
*/
|
||||
public function dumpToFile(string $filePath): int
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$prefix = $db->getPrefix();
|
||||
|
||||
$fp = fopen($filePath, 'w');
|
||||
|
||||
if ($fp === false) {
|
||||
throw new \RuntimeException('Cannot open dump file for writing: ' . $filePath);
|
||||
}
|
||||
|
||||
fwrite($fp, "-- MokoSuiteBackup Database Dump\n");
|
||||
fwrite($fp, "-- Generated: " . date('Y-m-d H:i:s') . "\n");
|
||||
fwrite($fp, "-- Server: " . $db->getServerType() . "\n");
|
||||
fwrite($fp, "-- Database: " . $db->getName() . "\n");
|
||||
fwrite($fp, "-- Original Prefix: " . $prefix . "\n");
|
||||
fwrite($fp, "-- Abstract Prefix: #__\n");
|
||||
fwrite($fp, "-- Note: Table names use #__ placeholder. Replace with your prefix on restore.\n\n");
|
||||
fwrite($fp, "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n");
|
||||
fwrite($fp, "SET time_zone = \"+00:00\";\n\n");
|
||||
|
||||
// Get all tables with the site prefix
|
||||
$tables = $db->getTableList();
|
||||
$siteTables = [];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (str_starts_with($table, $prefix)) {
|
||||
$siteTables[] = $table;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($siteTables as $table) {
|
||||
$abstractName = '#__' . substr($table, strlen($prefix));
|
||||
|
||||
if ($this->isExcludedBoth($abstractName, $table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$skipData = $this->isExcludedDataOnly($abstractName, $table);
|
||||
$skipStructure = $this->isExcludedStructureOnly($abstractName, $table);
|
||||
|
||||
$this->tablesCount++;
|
||||
|
||||
fwrite($fp, "-- --------------------------------------------------------\n");
|
||||
fwrite($fp, "-- Table: " . $abstractName . "\n");
|
||||
|
||||
if ($skipData) {
|
||||
fwrite($fp, "-- (data excluded)\n");
|
||||
}
|
||||
|
||||
if ($skipStructure) {
|
||||
fwrite($fp, "-- (structure excluded)\n");
|
||||
}
|
||||
|
||||
fwrite($fp, "-- --------------------------------------------------------\n\n");
|
||||
|
||||
if (!$skipStructure) {
|
||||
$db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
|
||||
$createRow = $db->loadRow();
|
||||
|
||||
if (!$createRow || empty($createRow[1])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$createSql = str_replace('`' . $prefix, '`#__', $createRow[1]);
|
||||
fwrite($fp, 'DROP TABLE IF EXISTS `' . $abstractName . "`;\\n");
|
||||
fwrite($fp, $createSql . ";\n\n");
|
||||
}
|
||||
|
||||
if ($skipData) {
|
||||
fwrite($fp, "\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table));
|
||||
$rowCount = (int) $db->loadResult();
|
||||
|
||||
if ($rowCount === 0) {
|
||||
fwrite($fp, "-- (empty table)\n\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
$chunkSize = 500;
|
||||
|
||||
for ($offset = 0; $offset < $rowCount; $offset += $chunkSize) {
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName($table)),
|
||||
$offset,
|
||||
$chunkSize
|
||||
);
|
||||
$rows = $db->loadAssocList();
|
||||
|
||||
if (empty($rows)) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$values = [];
|
||||
|
||||
foreach ($row as $value) {
|
||||
if ($value === null) {
|
||||
$values[] = 'NULL';
|
||||
} else {
|
||||
$values[] = $db->quote($value);
|
||||
}
|
||||
}
|
||||
|
||||
$columns = array_map([$db, 'quoteName'], array_keys($row));
|
||||
fwrite($fp, 'INSERT INTO `' . $abstractName . '`'
|
||||
. ' (' . implode(', ', $columns) . ')'
|
||||
. ' VALUES (' . implode(', ', $values) . ");\n");
|
||||
}
|
||||
}
|
||||
|
||||
fwrite($fp, "\n");
|
||||
}
|
||||
|
||||
fclose($fp);
|
||||
|
||||
return filesize($filePath) ?: 0;
|
||||
}
|
||||
|
||||
public function getTablesCount(): int
|
||||
{
|
||||
return $this->tablesCount;
|
||||
|
||||
@@ -114,19 +114,28 @@ class S3Uploader implements RemoteUploaderInterface
|
||||
*/
|
||||
private function singleUpload(string $localPath, string $objectKey): void
|
||||
{
|
||||
$url = $this->getObjectUrl($objectKey);
|
||||
$fileContent = file_get_contents($localPath);
|
||||
$contentHash = hash('sha256', $fileContent);
|
||||
$url = $this->getObjectUrl($objectKey);
|
||||
$fileSize = filesize($localPath);
|
||||
|
||||
// Stream file to compute SHA-256 without loading into RAM
|
||||
$contentHash = hash_file('sha256', $localPath);
|
||||
$headers = $this->signRequest('PUT', $url, $contentHash, [
|
||||
'Content-Type' => 'application/zip',
|
||||
'Content-Length' => (string) strlen($fileContent),
|
||||
'Content-Length' => (string) $fileSize,
|
||||
]);
|
||||
|
||||
$fp = fopen($localPath, 'rb');
|
||||
|
||||
if ($fp === false) {
|
||||
throw new \RuntimeException('Cannot open file for upload: ' . $localPath);
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_CUSTOMREQUEST => 'PUT',
|
||||
CURLOPT_POSTFIELDS => $fileContent,
|
||||
CURLOPT_PUT => true,
|
||||
CURLOPT_INFILE => $fp,
|
||||
CURLOPT_INFILESIZE => $fileSize,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 600,
|
||||
@@ -135,6 +144,8 @@ class S3Uploader implements RemoteUploaderInterface
|
||||
$response = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
fclose($fp);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
@@ -47,12 +47,14 @@ class TarGzArchiver implements ArchiverInterface
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
// Compress the .tar to .tar.gz
|
||||
$this->tar->compress(\Phar::GZ);
|
||||
|
||||
// Remove the uncompressed .tar
|
||||
if (is_file($this->tarPath)) {
|
||||
@unlink($this->tarPath);
|
||||
try {
|
||||
// Compress the .tar to .tar.gz
|
||||
$this->tar->compress(\Phar::GZ);
|
||||
} finally {
|
||||
// Always remove the uncompressed .tar, even if compress() fails
|
||||
if (is_file($this->tarPath)) {
|
||||
@unlink($this->tarPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user