Files
alt-mysql/tests/backup_restore_upload_path_test.php
Marek Miklewicz 60ae1e1697 Fix arbitrary file read via restore upload path (security)
stageRestoreUploadFromDirectAdminTemp() accepted the raw
source_file_upload POST value as a literal filesystem path with only
an is_file() check - no ownership or allowed-root validation, unlike
the sibling queueRestoreFromFilePath(). isAllowedSqlFile() only checks
the attacker-controlled display filename, never the real source path.
Since the whole plugin runs as root, any authenticated DirectAdmin user
could stage and restore-import any file readable by root on the
server (e.g. other users' alt-mysql.conf credentials), leaking its
contents via MySQL import error output or the resulting database.

Remove the unsafe raw-path candidate entirely; only accept files whose
basename matches one inside the already-safe, fixed set of known
DirectAdmin/system temp directories.
2026-07-04 21:53:21 +02:00

97 lines
3.1 KiB
PHP

<?php
declare(strict_types=1);
$pluginTempRoot = sys_get_temp_dir() . '/altmysql-plugin-root-' . bin2hex(random_bytes(4));
mkdir($pluginTempRoot, 0700, true);
define('PLUGIN_ROOT', $pluginTempRoot);
$pluginDir = dirname(__DIR__);
require_once $pluginDir . '/exec/lib/Settings.php';
require_once $pluginDir . '/exec/lib/DirectAdminUser.php';
require_once $pluginDir . '/exec/lib/BackupQueueService.php';
function fail(string $message): void
{
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
function assert_true(bool $condition, string $message): void
{
if (!$condition) {
fail($message);
}
}
function load_settings(string $contents): Settings
{
$path = tempnam(sys_get_temp_dir(), 'altmysql-settings-');
if ($path === false) {
fail('cannot create temp settings file');
}
file_put_contents($path, $contents);
$settings = Settings::load($path);
unlink($path);
return $settings;
}
function make_da_user(Settings $settings, string $username): DirectAdminUser
{
$class = new ReflectionClass(DirectAdminUser::class);
/** @var DirectAdminUser $user */
$user = $class->newInstanceWithoutConstructor();
foreach ([
'username' => $username,
'prefix' => $username . '_',
'userConf' => [],
'packageConf' => [],
'settings' => $settings,
] as $property => $value) {
$prop = $class->getProperty($property);
$prop->setAccessible(true);
$prop->setValue($user, $value);
}
return $user;
}
$settings = load_settings('');
$daUser = make_da_user($settings, 'demo');
$queue = new BackupQueueService($settings, $daUser);
// A path outside every recognized DirectAdmin/system temp directory must be
// rejected, even if it exists and is readable - it must not be treated as a
// legitimate upload reference just because the caller supplied it verbatim.
$secretDir = sys_get_temp_dir() . '/altmysql-secret-' . bin2hex(random_bytes(4));
mkdir($secretDir, 0700, true);
$secretFile = $secretDir . '/secret_credentials.sql';
file_put_contents($secretFile, 'SECRET CONTENT THAT MUST NEVER BE COPIED');
$threw = false;
try {
$queue->stageRestoreUploadFromDirectAdminTemp($secretFile, 'harmless.sql');
} catch (Throwable $e) {
$threw = true;
}
assert_true($threw, 'a path outside the known DirectAdmin/system temp directories must be rejected, not staged');
// A legitimate reference - a file that DirectAdmin actually placed directly
// inside a recognized temp directory - must still work.
$legitFile = sys_get_temp_dir() . '/altmysql-da-upload-' . bin2hex(random_bytes(4)) . '.sql';
file_put_contents($legitFile, 'SELECT 1;');
$stagedPath = $queue->stageRestoreUploadFromDirectAdminTemp($legitFile, 'legit.sql');
assert_true(is_file($stagedPath), 'a file directly inside a recognized temp directory must still be staged');
assert_true(
trim((string)file_get_contents($stagedPath)) === 'SELECT 1;',
'the staged file must contain the legitimate source content'
);
// cleanup
@unlink($secretFile);
@rmdir($secretDir);
@unlink($legitFile);
@unlink($stagedPath);
exec('rm -rf ' . escapeshellarg($pluginTempRoot));
echo "backup_restore_upload_path_test: OK\n";