From 909e776fbcb78c7e72fc08b16d1591b604946090 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:12:59 +0200 Subject: [PATCH] fix(database): allow PostgreSQL custom format backups safely Detect custom format archives before scanning for blocked commands and tighten PostgreSQL restore command matching. --- app/Livewire/Project/Database/ImportForm.php | 13 +++++------ app/Support/DatabaseBackupFileValidator.php | 8 +++++-- .../DatabaseBackupUploadValidationTest.php | 22 +++++++++++++++++++ 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php index 62d4e1a59..ccd343510 100644 --- a/app/Livewire/Project/Database/ImportForm.php +++ b/app/Livewire/Project/Database/ImportForm.php @@ -812,14 +812,13 @@ EOD; // /* ... */ block comment (used to split keywords like FROM/**/PROGRAM). $sep = '([[:space:]]|/\\*[^*]*\\*/)'; - $pattern = implode('|', [ - "copy{$sep}+[^;]*(from|to){$sep}+program", - '(^|[[:space:]])\\\\!', - "(^|[[:space:]])\\\\(o|g){$sep}*\\|", - ]); - $escapedPattern = escapeshellarg($pattern); + $sqlPattern = "(^|;){$sep}*copy{$sep}+[^;]*(from|to){$sep}+program"; + $psqlPattern = "^{$sep}*\\\\(!|copy{$sep}+[^[:space:]]+.*{$sep}+program|(o|g){$sep}*\\|)"; + $escapedSqlPattern = escapeshellarg($sqlPattern); + $escapedPsqlPattern = escapeshellarg($psqlPattern); + $contents = "{ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; }"; - return "if (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi"; + return "header=\$({$contents} | head -c 5); if [ \"\$header\" = 'PGDMP' ]; then exit 0; fi; if {$contents} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$contents} | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedSqlPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi"; } private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void diff --git a/app/Support/DatabaseBackupFileValidator.php b/app/Support/DatabaseBackupFileValidator.php index c232462f6..2c1de948b 100644 --- a/app/Support/DatabaseBackupFileValidator.php +++ b/app/Support/DatabaseBackupFileValidator.php @@ -90,13 +90,17 @@ class DatabaseBackupFileValidator public static function containsPostgresqlProgramExecution(string $sql): bool { + if (str_starts_with($sql, 'PGDMP')) { + return false; + } + $withoutComments = self::stripSqlComments($sql); - if (preg_match('/^\s*\\\\(?:!|copy\b.*\bprogram\b)/mi', $withoutComments) === 1) { + if (preg_match('/^\s*\\\\(?:!|copy\b[^\r\n]*\bprogram\b|(?:o|g)\s*\|)/mi', $withoutComments) === 1) { return true; } - return preg_match('/\bcopy\b[\s\S]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; + return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; } private static function extensionFor(string $name): ?string diff --git a/tests/Feature/DatabaseBackupUploadValidationTest.php b/tests/Feature/DatabaseBackupUploadValidationTest.php index 1919b02b9..74835feef 100644 --- a/tests/Feature/DatabaseBackupUploadValidationTest.php +++ b/tests/Feature/DatabaseBackupUploadValidationTest.php @@ -185,6 +185,24 @@ test('file scanner allows ordinary gzipped dumps', function () { expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzClean))->toBeFalse(); }); +test('file scanner allows PostgreSQL custom format archives', function () { + $archive = writeScanPayload("PGDMP\0binary COPY records FROM PROGRAM payload"); + + expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeFalse(); +}); + +test('file scanner allows gzipped PostgreSQL custom format archives', function () { + $archive = writeScanPayload("PGDMP\0binary COPY records FROM PROGRAM payload", gzip: true); + + expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeFalse(); +}); + +test('postgresql backup safety scanner allows copy words in table data', function () { + $dump = "COPY notes FROM stdin;\n1\tcopy files from program storage\n\\.\n"; + + expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($dump))->toBeFalse(); +}); + test('backup validator rejects plaintext .dump containing program execution', function () { $file = makeTemporaryUpload('evil.dump', "COPY x FROM PROGRAM 'id';\n"); @@ -206,6 +224,7 @@ test('remote postgresql scanner blocks bypass payloads', function (string $conte 'copy split across lines' => ["COPY x FROM\nPROGRAM 'id';\n", false], 'copy to program' => ["COPY x TO PROGRAM 'cat > /tmp/x';\n", false], 'psql pipe redirect' => ["\\o | id\n", false], + 'psql query pipe redirect' => ["\\g | id\n", false], 'gzipped comment bypass' => ["COPY x FROM/**/PROGRAM 'id';\n", true], ]); @@ -222,6 +241,9 @@ test('remote postgresql scanner allows legitimate restores', function (string $c 'copy from stdin' => ["COPY users FROM stdin;\n1\tTaylor\n\\.\n", false], 'plain select' => ["SELECT * FROM users;\n", false], 'gzipped clean dump' => ["CREATE TABLE users (id int);\n", true], + 'custom format archive' => ["PGDMP\0binary COPY records FROM PROGRAM payload", false], + 'custom format gzip archive' => ["PGDMP\0binary COPY records FROM PROGRAM payload", true], + 'copy words in table data' => ["COPY notes FROM stdin;\n1\tcopy files from program storage\n\\.\n", false], ]); test('MAX_BYTES constant is 10 GiB', function () {