Skip to content

Commit 057b167

Browse files
committed
feat(update): enhance backup filename security with random hash, backported from main
1 parent 61829e8 commit 057b167

5 files changed

Lines changed: 88 additions & 15 deletions

File tree

nginx.conf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ server {
5757
add_header Cache-Control "public, max-age=31536000, immutable";
5858
}
5959

60+
# Block zip files in content directory
61+
location ~ ^/content/.*\.zip$ {
62+
deny all;
63+
return 403;
64+
}
65+
6066
# Rewrite logging, should be turned off on production
6167
rewrite_log on
6268

phpmyfaq/.htaccess

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ Header set Access-Control-Allow-Headers "Content-Type, Authorization"
9898
</IfModule>
9999
# the path to your phpMyFAQ installation
100100
RewriteBase /
101+
# Block zip files in content directory
102+
RewriteRule ^content/.*\.zip$ - [F,L]
101103
# Error pages
102104
ErrorDocument 404 /index.php?action=404
103105
# General pages

phpmyfaq/src/phpMyFAQ/Controller/Api/SetupController.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ class SetupController extends AbstractController
3131
{
3232
public function check(Request $request): JsonResponse
3333
{
34+
$this->userIsAuthenticated();
35+
3436
if (empty($request->getContent())) {
3537
return $this->json(['message' => 'No version given.'], Response::HTTP_BAD_REQUEST);
3638
}
@@ -67,6 +69,8 @@ public function check(Request $request): JsonResponse
6769

6870
public function backup(Request $request): JsonResponse
6971
{
72+
$this->userIsAuthenticated();
73+
7074
if (empty($request->getContent())) {
7175
return $this->json(['message' => 'No version given.'], Response::HTTP_BAD_REQUEST);
7276
}
@@ -92,6 +96,8 @@ public function backup(Request $request): JsonResponse
9296

9397
public function updateDatabase(Request $request): JsonResponse
9498
{
99+
$this->userIsAuthenticated();
100+
95101
if (empty($request->getContent())) {
96102
return $this->json(['message' => 'No version given.'], Response::HTTP_BAD_REQUEST);
97103
}

phpmyfaq/src/phpMyFAQ/Setup/Update.php

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@
2727
use phpMyFAQ\Setup;
2828
use phpMyFAQ\System;
2929
use phpMyFAQ\User;
30+
use Random\RandomException;
3031
use RecursiveDirectoryIterator;
3132
use RecursiveIteratorIterator;
33+
use SplFileInfo;
3234
use SplFileObject;
3335
use Symfony\Component\HttpFoundation\Request;
3436
use Tivie\HtaccessParser\Exception\SyntaxException;
@@ -49,6 +51,8 @@ class Update extends Setup
4951
/** @var string[] */
5052
private array $dryRunQueries = [];
5153

54+
private ?string $backupFilename = null;
55+
5256
public function __construct(protected System $system, private readonly Configuration $configuration)
5357
{
5458
parent::__construct($this->system);
@@ -72,6 +76,7 @@ public function isConfigTableNotAvailable(DatabaseDriver $databaseDriver): bool
7276
/**
7377
* Creates a backup of the current config files
7478
* @throws Exception
79+
* @throws RandomException
7580
*/
7681
public function createConfigBackup(string $configDir): string
7782
{
@@ -84,19 +89,44 @@ public function createConfigBackup(string $configDir): string
8489

8590
$files = new RecursiveIteratorIterator(
8691
new RecursiveDirectoryIterator($configDir),
87-
RecursiveIteratorIterator::SELF_FIRST
92+
RecursiveIteratorIterator::SELF_FIRST,
8893
);
8994

90-
foreach ($files as $file) {
91-
$file = realpath($file);
92-
if (str_contains($file, $configDir . DIRECTORY_SEPARATOR)) {
93-
if (is_dir($file)) {
94-
$zipArchive->addEmptyDir(
95-
str_replace($configDir . DIRECTORY_SEPARATOR, '', $file . DIRECTORY_SEPARATOR)
96-
);
97-
} elseif (is_file($file)) {
98-
$zipArchive->addFile($file, str_replace($configDir . DIRECTORY_SEPARATOR, '', $file));
99-
}
95+
foreach ($files as $fileInfo) {
96+
if ($fileInfo instanceof SplFileInfo) {
97+
$filePath = $fileInfo->getRealPath() ?: $fileInfo->getPathname();
98+
$isDir = $fileInfo->isDir();
99+
$isFile = $fileInfo->isFile();
100+
} else {
101+
$filePath = is_string($fileInfo) ? $fileInfo : (string) $fileInfo;
102+
$filePath = realpath($filePath) ?: $filePath;
103+
$isDir = is_dir($filePath);
104+
$isFile = is_file($filePath);
105+
}
106+
107+
if ($filePath === false || $filePath === null || $filePath === '') {
108+
continue;
109+
}
110+
111+
// Exclude the zip we are currently writing
112+
if ($filePath === $outputZipFile) {
113+
continue;
114+
}
115+
116+
// Only include entries inside the config directory
117+
if (!str_contains($filePath, $configDir . DIRECTORY_SEPARATOR) && $filePath !== $configDir) {
118+
continue;
119+
}
120+
121+
// Compute a relative path inside the archive
122+
$relativePath = str_replace($configDir . DIRECTORY_SEPARATOR, '', $filePath);
123+
$relativePath = ltrim($relativePath, DIRECTORY_SEPARATOR);
124+
125+
if ($isDir) {
126+
// Ensure directory entries end with a slash
127+
$zipArchive->addEmptyDir(rtrim($relativePath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
128+
} elseif ($isFile) {
129+
$zipArchive->addFile($filePath, $relativePath);
100130
}
101131
}
102132

@@ -1096,8 +1126,15 @@ private function updateVersion(): void
10961126
$this->configuration->update(['main.currentVersion' => System::getVersion()]);
10971127
}
10981128

1129+
/**
1130+
* @throws RandomException
1131+
*/
10991132
private function getBackupFilename(): string
11001133
{
1101-
return sprintf('phpmyfaq-config-backup.%s.zip', date('Y-m-d'));
1134+
if ($this->backupFilename === null) {
1135+
$randomHash = bin2hex(random_bytes(4)); // 8-character hex string
1136+
$this->backupFilename = sprintf('phpmyfaq-config-backup.%s.%s.zip', date(format: 'Y-m-d'), $randomHash);
1137+
}
1138+
return $this->backupFilename;
11021139
}
11031140
}

tests/phpMyFAQ/Setup/UpdateTest.php

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use phpMyFAQ\Database\Sqlite3;
88
use phpMyFAQ\System;
99
use PHPUnit\Framework\TestCase;
10+
use Random\RandomException;
1011

1112
class UpdateTest extends TestCase
1213
{
@@ -27,19 +28,40 @@ protected function setUp(): void
2728

2829
/**
2930
* @throws Exception
31+
* @throws RandomException
3032
*/
3133
public function testCreateConfigBackup(): void
3234
{
3335
$this->update->setVersion('4.0.0');
3436
$configPath = PMF_TEST_DIR . '/content/core/config';
3537

38+
// Clean up any existing backup files before test
39+
$existingFiles = glob($configPath . '/phpmyfaq-config-backup.*.zip');
40+
foreach ($existingFiles as $file) {
41+
if (file_exists($file)) {
42+
unlink($file);
43+
}
44+
}
45+
3646
$this->update->createConfigBackup($configPath);
3747

38-
$this->assertFileExists(
39-
PMF_TEST_DIR . '/content/core/config/phpmyfaq-config-backup.' . date('Y-m-d') . '.zip'
48+
// Find a backup file with a pattern: phpmyfaq-config-backup.YYYY-MM-DD.XXXXXXXX.zip
49+
$pattern = PMF_TEST_DIR . '/content/core/config/phpmyfaq-config-backup.' . date(format: 'Y-m-d') . '.*.zip';
50+
$files = glob($pattern);
51+
52+
$this->assertNotEmpty($files, 'Backup file should exist with random hash');
53+
$this->assertCount(1, $files, 'Exactly one backup file should exist');
54+
55+
// Verify filename format: date.hash.zip where hash is 8 hex characters
56+
$filename = basename($files[0]);
57+
$this->assertMatchesRegularExpression(
58+
'/^phpmyfaq-config-backup\.\d{4}-\d{2}-\d{2}\.[0-9a-f]{8}\.zip$/',
59+
$filename,
60+
'Backup filename should contain 8-character hexadecimal hash'
4061
);
4162

42-
unlink(PMF_TEST_DIR . '/content/core/config/phpmyfaq-config-backup.' . date('Y-m-d') . '.zip');
63+
// Cleanup
64+
unlink($files[0]);
4365
}
4466

4567
public function testIsConfigTableNotAvailable(): void

0 commit comments

Comments
 (0)