fix(backup): validate MongoDB collection names in backup input (#9168)
This commit is contained in:
commit
b1de75a7c6
5 changed files with 150 additions and 16 deletions
|
|
@ -792,6 +792,18 @@ public function create_backup(Request $request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate databases_to_backup input
|
||||||
|
if (! empty($backupData['databases_to_backup'])) {
|
||||||
|
try {
|
||||||
|
validateDatabasesBackupInput($backupData['databases_to_backup']);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['databases_to_backup' => [$e->getMessage()]],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add required fields
|
// Add required fields
|
||||||
$backupData['database_id'] = $database->id;
|
$backupData['database_id'] = $database->id;
|
||||||
$backupData['database_type'] = $database->getMorphClass();
|
$backupData['database_type'] = $database->getMorphClass();
|
||||||
|
|
@ -997,6 +1009,18 @@ public function update_backup(Request $request)
|
||||||
unset($backupData['s3_storage_uuid']);
|
unset($backupData['s3_storage_uuid']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate databases_to_backup input
|
||||||
|
if (! empty($backupData['databases_to_backup'])) {
|
||||||
|
try {
|
||||||
|
validateDatabasesBackupInput($backupData['databases_to_backup']);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => ['databases_to_backup' => [$e->getMessage()]],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$backupConfig->update($backupData);
|
$backupConfig->update($backupData);
|
||||||
|
|
||||||
if ($request->backup_now) {
|
if ($request->backup_now) {
|
||||||
|
|
|
||||||
|
|
@ -524,10 +524,18 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi
|
||||||
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Validate and escape each collection name
|
||||||
|
$escapedCollections = $collectionsToExclude->map(function ($collection) {
|
||||||
|
$collection = trim($collection);
|
||||||
|
validateShellSafePath($collection, 'collection name');
|
||||||
|
|
||||||
|
return escapeshellarg($collection);
|
||||||
|
});
|
||||||
|
|
||||||
if (str($this->database->image)->startsWith('mongo:4')) {
|
if (str($this->database->image)->startsWith('mongo:4')) {
|
||||||
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
||||||
} else {
|
} else {
|
||||||
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,21 +105,9 @@ public function syncData(bool $toModel = false)
|
||||||
$this->backup->s3_storage_id = $this->s3StorageId;
|
$this->backup->s3_storage_id = $this->s3StorageId;
|
||||||
|
|
||||||
// Validate databases_to_backup to prevent command injection
|
// Validate databases_to_backup to prevent command injection
|
||||||
|
// Handles all formats including MongoDB's "db:col1,col2|db2:col3"
|
||||||
if (filled($this->databasesToBackup)) {
|
if (filled($this->databasesToBackup)) {
|
||||||
$databases = str($this->databasesToBackup)->explode(',');
|
validateDatabasesBackupInput($this->databasesToBackup);
|
||||||
foreach ($databases as $index => $db) {
|
|
||||||
$dbName = trim($db);
|
|
||||||
try {
|
|
||||||
validateShellSafePath($dbName, 'database name');
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
// Provide specific error message indicating which database failed validation
|
|
||||||
$position = $index + 1;
|
|
||||||
throw new \Exception(
|
|
||||||
"Database #{$position} ('{$dbName}') validation failed: ".
|
|
||||||
$e->getMessage()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->backup->databases_to_backup = $this->databasesToBackup;
|
$this->backup->databases_to_backup = $this->databasesToBackup;
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,59 @@ function validateShellSafePath(string $input, string $context = 'path'): string
|
||||||
return $input;
|
return $input;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a databases_to_backup input string is safe from command injection.
|
||||||
|
*
|
||||||
|
* Supports all database formats:
|
||||||
|
* - PostgreSQL/MySQL/MariaDB: "db1,db2,db3"
|
||||||
|
* - MongoDB: "db1:col1,col2|db2:col3,col4"
|
||||||
|
*
|
||||||
|
* Validates each database name AND collection name individually against shell metacharacters.
|
||||||
|
*
|
||||||
|
* @param string $input The databases_to_backup string
|
||||||
|
* @return string The validated input
|
||||||
|
*
|
||||||
|
* @throws \Exception If any component contains dangerous characters
|
||||||
|
*/
|
||||||
|
function validateDatabasesBackupInput(string $input): string
|
||||||
|
{
|
||||||
|
// Split by pipe (MongoDB multi-db separator)
|
||||||
|
$databaseEntries = explode('|', $input);
|
||||||
|
|
||||||
|
foreach ($databaseEntries as $entry) {
|
||||||
|
$entry = trim($entry);
|
||||||
|
if ($entry === '' || $entry === 'all' || $entry === '*') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($entry, ':')) {
|
||||||
|
// MongoDB format: dbname:collection1,collection2
|
||||||
|
$databaseName = str($entry)->before(':')->value();
|
||||||
|
$collections = str($entry)->after(':')->explode(',');
|
||||||
|
|
||||||
|
validateShellSafePath($databaseName, 'database name');
|
||||||
|
|
||||||
|
foreach ($collections as $collection) {
|
||||||
|
$collection = trim($collection);
|
||||||
|
if ($collection !== '') {
|
||||||
|
validateShellSafePath($collection, 'collection name');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Simple format: just a database name (may contain commas for non-Mongo)
|
||||||
|
$databases = explode(',', $entry);
|
||||||
|
foreach ($databases as $db) {
|
||||||
|
$db = trim($db);
|
||||||
|
if ($db !== '' && $db !== 'all' && $db !== '*') {
|
||||||
|
validateShellSafePath($db, 'database name');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $input;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that a string is a safe git ref (commit SHA, branch name, tag, or HEAD).
|
* Validate that a string is a safe git ref (commit SHA, branch name, tag, or HEAD).
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -81,3 +81,64 @@
|
||||||
expect(fn () => validateShellSafePath('test123', 'database name'))
|
expect(fn () => validateShellSafePath('test123', 'database name'))
|
||||||
->not->toThrow(Exception::class);
|
->not->toThrow(Exception::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- MongoDB collection name validation tests ---
|
||||||
|
|
||||||
|
test('mongodb collection name rejects command substitution injection', function () {
|
||||||
|
expect(fn () => validateShellSafePath('$(touch /tmp/pwned)', 'collection name'))
|
||||||
|
->toThrow(Exception::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mongodb collection name rejects backtick injection', function () {
|
||||||
|
expect(fn () => validateShellSafePath('`id > /tmp/pwned`', 'collection name'))
|
||||||
|
->toThrow(Exception::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mongodb collection name rejects semicolon injection', function () {
|
||||||
|
expect(fn () => validateShellSafePath('col1; rm -rf /', 'collection name'))
|
||||||
|
->toThrow(Exception::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mongodb collection name rejects ampersand injection', function () {
|
||||||
|
expect(fn () => validateShellSafePath('col1 & whoami', 'collection name'))
|
||||||
|
->toThrow(Exception::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mongodb collection name rejects redirect injection', function () {
|
||||||
|
expect(fn () => validateShellSafePath('col1 > /tmp/pwned', 'collection name'))
|
||||||
|
->toThrow(Exception::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateDatabasesBackupInput validates mongodb format with collection names', function () {
|
||||||
|
// Valid MongoDB formats should pass
|
||||||
|
expect(fn () => validateDatabasesBackupInput('mydb'))
|
||||||
|
->not->toThrow(Exception::class);
|
||||||
|
|
||||||
|
expect(fn () => validateDatabasesBackupInput('mydb:col1,col2'))
|
||||||
|
->not->toThrow(Exception::class);
|
||||||
|
|
||||||
|
expect(fn () => validateDatabasesBackupInput('db1:col1,col2|db2:col3'))
|
||||||
|
->not->toThrow(Exception::class);
|
||||||
|
|
||||||
|
expect(fn () => validateDatabasesBackupInput('all'))
|
||||||
|
->not->toThrow(Exception::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateDatabasesBackupInput rejects injection in collection names', function () {
|
||||||
|
// Command substitution in collection name
|
||||||
|
expect(fn () => validateDatabasesBackupInput('mydb:$(touch /tmp/pwned)'))
|
||||||
|
->toThrow(Exception::class);
|
||||||
|
|
||||||
|
// Backtick injection in collection name
|
||||||
|
expect(fn () => validateDatabasesBackupInput('mydb:`id`'))
|
||||||
|
->toThrow(Exception::class);
|
||||||
|
|
||||||
|
// Semicolon in collection name
|
||||||
|
expect(fn () => validateDatabasesBackupInput('mydb:col1;rm -rf /'))
|
||||||
|
->toThrow(Exception::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateDatabasesBackupInput rejects injection in database name within mongo format', function () {
|
||||||
|
expect(fn () => validateDatabasesBackupInput('$(whoami):col1,col2'))
|
||||||
|
->toThrow(Exception::class);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue