fix(database): allow PostgreSQL custom format backups safely

Detect custom format archives before scanning for blocked commands and tighten PostgreSQL restore command matching.
This commit is contained in:
Andras Bacsai
2026-08-15 15:12:59 +02:00
parent 60f9fdd827
commit 909e776fbc
3 changed files with 34 additions and 9 deletions
+6 -7
View File
@@ -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
+6 -2
View File
@@ -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
@@ -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 () {