$env */ function scannerBlocks(string $script, array $env = []): bool { $command = $script; if ($env !== []) { $exports = []; foreach ($env as $name => $value) { $exports[] = $name.'='.escapeshellarg($value); } $command = implode(' ', $exports).'; '.$script; } return Process::run(['sh', '-c', $command])->exitCode() === 1; } function fakePgRestorePath(string $sql, int $listExitCode = 0): string { $dir = sys_get_temp_dir().'/coolify-fake-pg-'.uniqid(); mkdir($dir); $sqlFile = $dir.'/archive.sql'; file_put_contents($sqlFile, $sql); $escapedSqlFile = escapeshellarg($sqlFile); file_put_contents($dir.'/pg_restore', <<setAccessible(true); return $method->invoke(null, $name); } function postgresScanScript(string $path): ?string { $database = Mockery::mock(StandalonePostgresql::class); $database->shouldReceive('getMorphClass')->andReturn(StandalonePostgresql::class); return (new DatabaseImportCommandBuilder)->buildPostgresRestoreScanScript($database, $path); } function makeTemporaryUpload(string $name, string $content): UploadedFile { $path = tempnam(sys_get_temp_dir(), 'coolify-upload-test-'); file_put_contents($path, $content); return new UploadedFile($path, $name, null, null, true); } test('hasAllowedExtension accepts supported extensions', function (string $name) { expect(invokeHasAllowedExtension($name))->toBeTrue(); })->with([ 'plain sql' => ['backup.sql'], 'uppercase sql' => ['BACKUP.SQL'], 'compound sql.gz' => ['backup.sql.gz'], 'compound tar.gz' => ['backup.tar.gz'], 'tgz' => ['archive.tgz'], 'zip' => ['dump.zip'], 'tar' => ['dump.tar'], 'gz' => ['data.gz'], 'dump' => ['data.dump'], 'bak' => ['data.bak'], 'bson' => ['data.bson'], 'bson.gz' => ['data.bson.gz'], 'archive' => ['data.archive'], 'archive.gz' => ['data.archive.gz'], 'bz2' => ['data.bz2'], 'xz' => ['data.xz'], 'sqlite db' => ['app.db'], 'sqlite' => ['app.sqlite'], 'sqlite3' => ['app.sqlite3'], 'gzip sqlite db' => ['app.db.gz'], 'gzip sqlite' => ['app.sqlite.gz'], 'gzip sqlite3' => ['app.sqlite3.gz'], ]); test('hasAllowedExtension rejects unsupported or empty stems', function (string $name) { expect(invokeHasAllowedExtension($name))->toBeFalse(); })->with([ 'php' => ['shell.php'], 'phtml' => ['shell.phtml'], 'sh' => ['run.sh'], 'exe' => ['malware.exe'], 'elf binary no ext' => ['payload'], 'html' => ['index.html'], 'bare compound without stem' => ['.sql.gz'], 'bare extension' => ['.sql'], 'empty string' => [''], 'misleading double ext' => ['shell.php.sql-evil'], ]); test('hasAllowedExtension rejects dangerous double extensions', function (string $name) { expect(invokeHasAllowedExtension($name))->toBeFalse(); })->with([ 'php sql' => ['evil.php.sql'], 'php gzip' => ['evil.php.gz'], 'shell tar' => ['evil.sh.tar'], 'php tar gzip' => ['shell.php.tar.gz'], 'exe zip' => ['cmd.exe.zip'], 'jsp sql' => ['evil.jsp.sql'], ]); test('backup validator rejects content that does not match the backup extension', function () { $file = makeTemporaryUpload('payload.sql.gz', 'not actually gzip'); expect(DatabaseBackupFileValidator::isUploadAllowed($file, 10 * 1024 * 1024))->toBeFalse(); }); test('backup validator accepts SQLite database files and their gzip form', function (string $name, string $content) { expect(DatabaseBackupFileValidator::isUploadAllowed(makeTemporaryUpload($name, $content), 10 * 1024 * 1024))->toBeTrue(); })->with([ 'db' => ['app.db', "SQLite format 3\0".str_repeat("\0", 84)], 'sqlite' => ['app.sqlite', "SQLite format 3\0".str_repeat("\0", 84)], 'sqlite3' => ['app.sqlite3', "SQLite format 3\0".str_repeat("\0", 84)], 'gzip sqlite' => ['app.sqlite.gz', gzencode("SQLite format 3\0".str_repeat("\0", 84))], ]); test('backup validator rejects SQLite extensions with content that is not a SQLite database', function (string $name, string $content) { expect(DatabaseBackupFileValidator::isUploadAllowed(makeTemporaryUpload($name, $content), 10 * 1024 * 1024))->toBeFalse(); })->with([ 'sql text as db' => ['app.db', "CREATE TABLE users (id integer);\n"], 'script as sqlite' => ['app.sqlite', "#!/bin/sh\nid\n"], 'plain sqlite as sqlite.gz' => ['app.sqlite.gz', "SQLite format 3\0".str_repeat("\0", 84)], ]); test('backup validator accepts valid plain sql and gzip backup content', function () { $plainSql = makeTemporaryUpload('backup.sql', "CREATE TABLE users (id integer);\n"); $gzipSql = makeTemporaryUpload('backup.sql.gz', gzencode("CREATE TABLE users (id integer);\n")); expect(DatabaseBackupFileValidator::isUploadAllowed($plainSql, 10 * 1024 * 1024))->toBeTrue() ->and(DatabaseBackupFileValidator::isUploadAllowed($gzipSql, 10 * 1024 * 1024))->toBeTrue(); }); test('postgresql backup safety scanner detects program execution payloads', function (string $payload) { expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($payload))->toBeTrue(); })->with([ 'copy from program' => ["COPY pwned FROM PROGRAM 'id';"], 'copy to program' => ["COPY pwned TO PROGRAM 'cat > /tmp/out';"], 'copy with block comment' => ["COPY pwned FROM/**/PROGRAM 'id';"], 'copy with asterisks in block comment' => ["COPY pwned FROM/*a*b*/PROGRAM 'id';"], 'copy with nested block comment' => ["COPY pwned FROM /* a /* b */ c */ PROGRAM 'id';"], 'copy with line comment' => ["COPY pwned FROM--note\nPROGRAM 'id';"], 'copy with mixed comments' => ["COPY pwned FROM /* -- */\n-- c\r\nPROGRAM 'id';"], 'psql shell command' => ["\\! id\n"], 'psql copy program' => ["\\copy pwned from program 'id'\n"], 'psql shell command after statement' => ["SELECT 1; \\! id\n"], 'psql write pipe' => ["\\w |id\n"], 'program inside DO block' => ["DO \$\$ BEGIN COPY t TO PROGRAM 'id'; END \$\$;"], 'program inside EXECUTE string' => ["DO \$\$ BEGIN EXECUTE 'COPY t TO PROGRAM ''id'''; END \$\$;"], 'program inside function body' => ["CREATE FUNCTION f() RETURNS void AS 'COPY t TO PROGRAM ''id''' LANGUAGE sql;"], 'copy with semicolon in string' => ["COPY (SELECT ';') TO PROGRAM 'id';"], 'program after string holding comment' => ["SELECT '--'; COPY t TO PROGRAM 'id';"], 'large object import' => ["SELECT lo_import('/etc/passwd');"], 'alter system' => ["ALTER SYSTEM SET archive_command = 'id';"], ]); test('postgresql backup safety scanner allows ordinary sql dumps', function () { $dump = <<<'SQL' -- PostgreSQL database dump CREATE TABLE users (id integer, name text); COPY users (id, name) FROM stdin; 1 Taylor \. SQL; expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($dump))->toBeFalse(); }); test('postgresql restore commands include a safety check before execution', function () { $database = Mockery::mock(StandalonePostgresql::class); $database->shouldReceive('getMorphClass')->andReturn(StandalonePostgresql::class); $command = (new DatabaseImportCommandBuilder)->buildPostgresSafetyCommand( $database, 'postgres-test', '/tmp/restore_test', ); expect($command) ->toContain('docker exec postgres-test') ->toContain('COPY ... PROGRAM') ->toContain('/tmp/restore_test') ->toContain('grep -Eiq') ->toContain('pg_restore -l'); }); test('file scanner detects program execution payloads inside gzipped backups', function () { $gzPayload = writeScanPayload("CREATE TABLE x();\nCOPY x FROM/**/PROGRAM 'id';\n", gzip: true); expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzPayload))->toBeTrue(); }); test('file scanner allows ordinary gzipped dumps', function () { $gzClean = writeScanPayload("CREATE TABLE x();\nCOPY x FROM stdin;\n1\\.\n", gzip: true); expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzClean))->toBeFalse(); }); test('file scanner defers custom format archives to pg_restore inspection', function () { $archive = writeScanPayload("PGDMP\0binary COPY records FROM PROGRAM payload"); expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeFalse(); }); test('file scanner defers gzipped custom format archives to pg_restore inspection', function () { $archive = writeScanPayload("PGDMP\0binary COPY records FROM PROGRAM payload", gzip: true); expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeFalse(); }); test('file scanner allows custom format archives without program execution', function () { $archive = writeScanPayload("PGDMP\0binary archive without restore programs"); expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeFalse(); }); test('postgresql backup safety scanner allows copy from stdin data', function () { $dump = "COPY notes FROM stdin;\n1\tTaylor wrote a note\n\\.\n"; expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($dump))->toBeFalse(); }); // pg_dump writes table data as COPY ... FROM stdin rows that end with a "\." line. The rows are // data, not SQL, so ordinary text in them must not block the restore. Statements outside such a // block are still scanned, also when they follow one. dataset('postgresql copy data rows', [ 'program words' => ["COPY public.notes (id, body) FROM stdin;\n1\thow to program\n2\timported from program files\n3\tcopy files to program storage\n\\.\n"], 'large object call text' => ["COPY public.notes (id, body) FROM stdin;\n1\tcall lo_import(path) on the server\n\\.\n"], 'alter system text' => ["COPY public.notes (id, body) FROM stdin;\n1\trun ALTER SYSTEM SET work_mem then reload\n\\.\n"], 'escaped tab before backquotes' => ["COPY public.notes (id, body) FROM stdin;\n1\ttab\\t `backticks` here\n\\.\n"], 'null row before text' => ["COPY a (x) FROM stdin;\n\\N\nfrom program\n\\.\n"], 'several blocks' => ["COPY a (x) FROM stdin;\nfrom program\n\\.\n\nCOPY b (y) FROM stdin;\n\\.\nCOPY c (z) FROM stdin;\nto program\n\\.\nSELECT 1;\n"], ]); dataset('postgresql statements around copy data', [ 'program statement after a copy block' => ["COPY notes FROM stdin;\n1\tok\n\\.\nCOPY x FROM PROGRAM 'id';\n"], 'shell command after a copy block' => ["COPY notes FROM stdin;\n1\tok\n\\.\n\\! id\n"], 'copy header that is not alone on its line' => ["SELECT 1; COPY notes FROM stdin;\nCOPY x FROM PROGRAM 'id';\n\\.\n"], 'program statement before a copy block' => ["COPY x TO PROGRAM 'id';\nCOPY notes FROM stdin;\n1\tok\n\\.\n"], 'commented-out program statement' => ["-- COPY x FROM PROGRAM 'id'\nSELECT 1;\n"], 'program statement in a block comment' => ["/* COPY x TO PROGRAM 'id' */\nSELECT 1;\n"], ]); test('postgresql backup safety scanner allows text in copy data rows', function (string $dump) { expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($dump))->toBeFalse(); })->with('postgresql copy data rows'); test('remote postgresql scanner allows text in copy data rows', function (string $dump) { expect(scannerBlocks(postgresScanScript(writeScanPayload($dump))))->toBeFalse(); })->with('postgresql copy data rows'); test('postgresql backup safety scanner blocks statements around copy data', function (string $dump) { expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($dump))->toBeTrue(); })->with('postgresql statements around copy data'); test('remote postgresql scanner blocks statements around copy data', function (string $dump) { expect(scannerBlocks(postgresScanScript(writeScanPayload($dump))))->toBeTrue(); })->with('postgresql statements around copy data'); test('postgresql backup safety scanner blocks program execution hidden in dynamic sql', function (string $payload) { expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($payload))->toBeTrue(); })->with([ 'DO block' => ["DO \$\$ BEGIN COPY t TO PROGRAM 'id'; END \$\$;"], 'EXECUTE string' => ["DO \$\$ BEGIN EXECUTE 'COPY t TO PROGRAM ''id'''; END \$\$;"], 'function body' => ["CREATE FUNCTION f() RETURNS void AS 'COPY t TO PROGRAM ''id''' LANGUAGE sql;"], 'semicolon in string' => ["COPY (SELECT ';') TO PROGRAM 'id';"], 'string holding comment marker' => ["SELECT '--'; COPY t TO PROGRAM 'id';"], 'lo_import' => ["SELECT lo_import('/etc/passwd');"], 'alter system' => ["ALTER SYSTEM SET archive_command = 'id';"], 'mid-statement psql shell' => ["SELECT 1; \\! id\n"], ]); // The upload does not know the target database type, so it does not run the PostgreSQL scan: // a MySQL dump can hold the same words. A PostgreSQL import scans the whole file when it starts. test('backup validator accepts SQL uploads that the PostgreSQL scan would block', function () { $file = makeTemporaryUpload('mysql.sql', "INSERT INTO `notes` VALUES (1,'imported from program files'),(2,'\\! not a command');\n"); expect(DatabaseBackupFileValidator::isUploadAllowed($file, 10 * 1024 * 1024))->toBeTrue() ->and(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($file->getPathname()))->toBeTrue(); }); test('remote postgresql scanner blocks bypass payloads', function (string $content, bool $gzip) { $payload = writeScanPayload($content, $gzip); $script = postgresScanScript($payload); expect(scannerBlocks($script))->toBeTrue(); })->with([ 'psql shell escape' => ["\\! id\n", false], 'copy from program' => ["COPY x FROM PROGRAM 'id';\n", false], 'copy with block comment' => ["COPY x FROM/**/PROGRAM 'id';\n", false], 'copy with asterisks in block comment' => ["COPY x FROM/*a*b*/PROGRAM 'id';\n", false], 'copy with nested block comment' => ["COPY x FROM /* a /* b */ c */ PROGRAM 'id';\n", false], 'copy with line comment' => ["COPY x FROM--note\nPROGRAM 'id';\n", false], 'copy with marker char in line comment' => ["COPY x FROM -- a~b\nPROGRAM 'id';\n", false], 'copy with mixed comments' => ["COPY x FROM /* -- */\n-- c\r\nPROGRAM 'id';\n", false], 'copy with block comment across lines' => ["COPY x FROM /* a\n-- */ PROGRAM 'id';\n", true], '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], 'psql write pipe redirect' => ["\\w |id\n", false], 'psql shell after statement' => ["SELECT 1; \\! id\n", false], 'gzipped comment bypass' => ["COPY x FROM/**/PROGRAM 'id';\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], // Bypasses of the former (^|;) ... [^;]* COPY-only pattern. 'program inside DO block' => ["DO \$\$ BEGIN COPY t TO PROGRAM 'id'; END \$\$;\n", false], 'program inside EXECUTE string' => ["DO \$\$ BEGIN EXECUTE 'COPY t TO PROGRAM ''id'''; END \$\$;\n", false], 'program inside function body' => ["CREATE FUNCTION f() RETURNS void AS 'COPY t TO PROGRAM ''id''' LANGUAGE sql;\n", false], 'copy with semicolon in string' => ["COPY (SELECT ';') TO PROGRAM 'id';\n", false], 'program after string holding comment' => ["SELECT '--'; COPY t TO PROGRAM 'id';\n", false], 'large object import' => ["SELECT lo_import('/etc/passwd');\n", false], 'alter system' => ["ALTER SYSTEM SET archive_command = 'id';\n", false], 'commented out payload is now blocked' => ["-- COPY x FROM PROGRAM 'id'\nSELECT 1;\n", false], ]); test('remote postgresql scanner allows legitimate restores', function (string $content, bool $gzip) { $payload = writeScanPayload($content, $gzip); $script = postgresScanScript($payload); expect(scannerBlocks($script))->toBeFalse(); })->with([ '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], 'copy keyword without program' => ["COPY users (id, name) FROM stdin;\n1\tTaylor\n\\.\n", false], 'program word alone in data' => ["COPY notes FROM stdin;\n1\tfinished the program today\n\\.\n", false], 'comments between other words' => ["SELECT a FROM /* x */ t -- note\nWHERE b ~ 'program';\n", false], ]); test('the postgres safety command survives the non-root sudo parser', function (string $content, bool $blocked) { $payload = writeScanPayload($content); $database = Mockery::mock(StandalonePostgresql::class); $database->shouldReceive('getMorphClass')->andReturn(StandalonePostgresql::class); $server = Mockery::mock(Server::class)->makePartial(); $server->user = 'ubuntu'; $command = (new DatabaseImportCommandBuilder)->buildPostgresSafetyCommand($database, 'scan-container', $payload); $elevated = parseCommandsByLineForSudo(collect([$command]), $server)[0]; expect($elevated)->toStartWith('sudo bash -c ') ->and($elevated)->not->toContain(' | sudo '); // Strip the host elevation and the container indirection so the embedded scan runs // locally against the payload file; this proves the parser kept the script quoting intact. $local = str_replace(['sudo bash -c', 'docker exec scan-container sh -c'], ['bash -c', 'sh -c'], $elevated); expect(Process::run(['sh', '-c', $local])->exitCode() === 1)->toBe($blocked); })->with([ 'blocks program payload' => ["COPY t TO PROGRAM 'id';\n", true], 'blocks dynamic sql payload' => ["DO \$\$ BEGIN COPY t TO PROGRAM 'id'; END \$\$;\n", true], 'allows a clean dump' => ["COPY users FROM stdin;\n1\tTaylor\n\\.\n", false], ]); test('remote postgresql scanner inspects custom archives instead of skipping them', function () { $safeArchive = writeScanPayload("PGDMP\0binary archive"); $maliciousSql = "COPY x FROM PROGRAM 'id';\n"; $safeSql = "CREATE TABLE users (id integer);\nCOPY users FROM stdin;\n1\tTaylor\n\\.\n"; $maliciousPath = fakePgRestorePath($maliciousSql); $safePath = fakePgRestorePath($safeSql); $unreadablePath = fakePgRestorePath($safeSql, listExitCode: 1); $path = getenv('PATH') ?: '/usr/bin:/bin'; expect(scannerBlocks(postgresScanScript($safeArchive), ['PATH' => $maliciousPath.':'.$path]))->toBeTrue() ->and(scannerBlocks(postgresScanScript($safeArchive), ['PATH' => $safePath.':'.$path]))->toBeFalse() ->and(scannerBlocks(postgresScanScript($safeArchive), ['PATH' => $unreadablePath.':'.$path]))->toBeTrue() ->and(scannerBlocks(postgresScanScript($safeArchive)))->toBeTrue(); }); test('MAX_BYTES constant is 10 GiB', function () { $constant = (new ReflectionClass(UploadController::class))->getConstant('MAX_BYTES'); expect($constant)->toBe(10 * 1024 * 1024 * 1024); }); test('ALLOWED_EXTENSIONS does not include executable formats', function () { $constant = (new ReflectionClass(UploadController::class))->getConstant('ALLOWED_EXTENSIONS'); expect($constant)->toBeArray(); $forbidden = ['php', 'phtml', 'php5', 'sh', 'bash', 'exe', 'js', 'html', 'htm', 'pl', 'py']; foreach ($forbidden as $bad) { expect($constant)->not->toContain($bad); } }); // psql meta-commands can follow SQL on the same line, so they are matched anywhere after an // odd backslash run. COPY data writes a literal backslash as "\\" and uses \n, \b, \v and \N, // which psql does not know as commands, so ordinary dumps must still pass. dataset('psql shell meta-commands', [ 'shell escape after sql without semicolon' => ["SELECT 1 \\! id\n"], 'output long name piped' => ["\\out |id\nSELECT 1;\n"], 'output piped after sql' => ["SELECT 1 \\o |id\n"], 'query piped with options' => ["SELECT 1 \\g (format=csv) |id\n"], 'expanded query piped' => ["SELECT 1 \\gx |id\n"], 'write long name piped' => ["\\write |id\n"], 'copy program after sql' => ["SELECT 1 \\copy t from program 'id'\n"], 'backquote in set' => ["\\set x `id`\n"], 'backquote in short command after sql' => ["SELECT 1 \\t `id`\n"], 'include file' => ["\\i /tmp/generated.sql\n"], 'include relative file' => ["\\include_relative generated.sql\n"], 'editor environment' => ["\\setenv PSQL_EDITOR id\n"], 'editor' => ["\\e\n"], 'shell escape after escaped backslash' => ["SELECT 1 \\\\\\! id\n"], ]); dataset('psql safe dump content', [ 'escaped backslash before bang in copy data' => ["COPY notes FROM stdin;\n1\tC:\\\\! dir\n\\.\n"], 'copy escapes before backquotes' => ["COPY notes FROM stdin;\n1\tline\\n `code` \\b `x` \\v `y`\t\\N\n\\.\n"], 'copy hex and octal escapes' => ["COPY notes FROM stdin;\n1\t\\x41 \\101 |pipe\n\\.\n"], 'pg_dumpall connect' => ["\\connect app\nSELECT 1;\n"], 'pg_dump restrict keys' => ["\\restrict abc123\nSELECT 1;\n\\unrestrict abc123\n"], ]); test('postgresql backup safety scanner blocks psql shell meta-commands anywhere on a line', function (string $payload) { expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($payload))->toBeTrue(); })->with('psql shell meta-commands'); test('postgresql backup safety scanner allows backslash sequences of ordinary dumps', function (string $payload) { expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($payload))->toBeFalse(); })->with('psql safe dump content'); test('remote postgresql scanner blocks psql shell meta-commands anywhere on a line', function (string $payload) { expect(scannerBlocks(postgresScanScript(writeScanPayload($payload))))->toBeTrue(); })->with('psql shell meta-commands'); test('remote postgresql scanner allows backslash sequences of ordinary dumps', function (string $payload) { expect(scannerBlocks(postgresScanScript(writeScanPayload($payload))))->toBeFalse(); })->with('psql safe dump content');