Files
occweb/tests/Unit/Service/SqlGuardTest.php
T
dillard f4b7484e2f Split SQL batches correctly around dollar quotes and comments
splitStatements() only knew 'strings' and "identifiers", so a DO block
or function body ($$ ... ; ... $$) was cut at its inner semicolons and
failed, and a quote inside a comment (-- don't) opened a string that
never closed. It now also skips dollar-quoted bodies, E'...' strings
with backslash escapes, and comments. It moves to SqlGuard so it is
tested with the other checks.

OccController::run() now catches Throwable, so an Error thrown by a
command is logged and returned like an Exception instead of ending in
an HTTP 500.

Not changed on purpose: showing rows for statements with RETURNING or
data-modifying CTEs, and failure handling in the Makefile release
targets. Both came up in the same review but are unrelated to the
security fixes in this release.
2026-09-30 03:32:58 +02:00

154 lines
5.3 KiB
PHP

<?php
namespace OCA\OCCWeb\Tests\Unit\Service;
use OCA\OCCWeb\Service\SqlGuard;
use PHPUnit\Framework\TestCase;
class SqlGuardTest extends TestCase
{
public static function forbiddenProvider(): array
{
return [
["COPY (SELECT 1) TO PROGRAM 'sh'"],
["COPY (SELECT 'id') TO/**/PROGRAM 'sh'"],
["COPY (SELECT 1) TO--x\nPROGRAM 'sh'"],
["COPY (SELECT 1) TO '/tmp/x'"],
["COPY (SELECT current_user) TO/**/'/tmp/x'"],
["COPY (SELECT 1) TO E'/tmp/x'"],
["COPY (SELECT 1) TO \$\$/tmp/x\$\$"],
["COPY (SELECT 1) TO \$f\$/tmp/x\$f\$"],
["COPY oc_users FROM '/etc/passwd'"],
["SELECT pg_read_file/**/('/etc/passwd')"],
["SELECT pg_ls_dir('/')"],
["SELECT lo_import('/etc/passwd')"],
["SELECT dblink_connect('host=x')"],
["SELECT * FROM t INTO OUTFILE '/tmp/x'"],
];
}
/**
* @dataProvider forbiddenProvider
*/
public function testForbidden(string $sql): void
{
$this->assertNotNull(SqlGuard::findForbiddenConstruct($sql));
}
public static function allowedProvider(): array
{
return [
['SELECT * FROM oc_users'],
['COPY (SELECT 1) TO STDOUT'],
];
}
/**
* @dataProvider allowedProvider
*/
public function testAllowed(string $sql): void
{
$this->assertNull(SqlGuard::findForbiddenConstruct($sql));
}
public static function readOnlyProvider(): array
{
return [
['SELECT * FROM oc_users'],
[' -- comment' . "\n" . 'SELECT 1'],
['/* comment */ SELECT 1'],
['(SELECT 1) UNION (SELECT 2)'],
['WITH x AS (SELECT 1) SELECT * FROM x'],
["SELECT * FROM oc_jobs WHERE class = 'OCA\\\\Mail\\\\DeleteJob'"],
["SELECT * FROM t WHERE status = 'deleted' OR note = 'update into'"],
['SELECT "update" FROM t'],
["SELECT replace(name, 'a', 'b') FROM t"],
['SHOW search_path'],
['EXPLAIN SELECT 1'],
["SET myvar.uid = 'alice'"],
["SELECT \$\$DELETE FROM x\$\$"],
["SELECT E'it\\'s delete' FROM t"],
['VALUES (1)'],
];
}
/**
* @dataProvider readOnlyProvider
*/
public function testReadOnly(string $sql): void
{
$this->assertTrue(SqlGuard::isReadOnly($sql), $sql);
}
public static function writeProvider(): array
{
return [
['DELETE FROM oc_preferences'],
['/* not a leading -- comment */ DELETE FROM oc_preferences'],
["-- x\nUPDATE t SET a = 1"],
['WITH removed AS (DELETE FROM oc_preferences RETURNING *) SELECT count(*) FROM removed'],
['WITH x AS (UPDATE t SET a = 1 RETURNING *) SELECT 1'],
['INSERT INTO t VALUES (1)'],
['TRUNCATE oc_preferences'],
['DROP TABLE t'],
['ALTER TABLE t ADD c int'],
['CREATE TABLE t (a int)'],
['GRANT ALL ON t TO x'],
["DO \$\$BEGIN PERFORM 1; END\$\$"],
['CALL p()'],
['SELECT * INTO newtable FROM t'],
['SELECT * FROM t FOR UPDATE'],
['EXPLAIN ANALYZE DELETE FROM t'],
['SET ROLE postgres'],
['SET SESSION AUTHORIZATION postgres'],
['SELECT pg_terminate_backend(123)'],
["SELECT set_config('x', 'y', false)"],
["SELECT setval('seq', 1)"],
['MERGE INTO t USING s ON true WHEN MATCHED THEN DELETE'],
['VACUUM'],
['', ],
];
}
/**
* @dataProvider writeProvider
*/
public function testNeedsConfirmation(string $sql): void
{
$this->assertFalse(SqlGuard::isReadOnly($sql), $sql);
}
public function testStatementType(): void
{
$this->assertSame('DELETE', SqlGuard::statementType('/* x */ delete from t'));
$this->assertSame('WITH', SqlGuard::statementType("-- x\nWITH a AS (SELECT 1) SELECT 1"));
$this->assertSame('SELECT', SqlGuard::statementType('(SELECT 1)'));
$this->assertSame('', SqlGuard::statementType(''));
}
public static function splitProvider(): array
{
return [
['SELECT 1; SELECT 2', ['SELECT 1', 'SELECT 2']],
["SELECT ';'; SELECT 2", ["SELECT ';'", 'SELECT 2']],
["SELECT 'it''s; x'", ["SELECT 'it''s; x'"]],
['SELECT "a;b" FROM t', ['SELECT "a;b" FROM t']],
["SELECT E'it\\'s; x'; SELECT 2", ["SELECT E'it\\'s; x'", 'SELECT 2']],
["DO \$\$BEGIN PERFORM 1; PERFORM 2; END\$\$; SELECT 3", ["DO \$\$BEGIN PERFORM 1; PERFORM 2; END\$\$", 'SELECT 3']],
["SELECT \$f\$a;\$\$;b\$f\$; SELECT 2", ["SELECT \$f\$a;\$\$;b\$f\$", 'SELECT 2']],
["-- don't; split\nSELECT 1; SELECT 2", ["-- don't; split\nSELECT 1", 'SELECT 2']],
["/* it's; here */ SELECT 1; SELECT 2", ["/* it's; here */ SELECT 1", 'SELECT 2']],
['SELECT \$1; SELECT 2', ['SELECT \$1', 'SELECT 2']],
[' ; ;SELECT 1;', ['SELECT 1']],
];
}
/**
* @dataProvider splitProvider
*/
public function testSplitStatements(string $sql, array $expected): void
{
$this->assertSame($expected, SqlGuard::splitStatements($sql));
}
}