Files
occweb/tests/Unit/Service/SqlGuardTest.php
T
dillard f68b1658be Harden SQL mode checks and sanitize occ output
A security review found three issues:

- Comments were removed without a space before matching the forbidden
  patterns, so "TO/**/PROGRAM" became "TOPROGRAM" and passed the
  COPY ... PROGRAM check while PostgreSQL still ran it. Comments are
  now replaced by a space, and the COPY file check also covers E'...',
  U&'...' and dollar-quoted file names.
- Only statements starting with DELETE or UPDATE asked for
  confirmation, so a leading block comment, a data-modifying CTE
  (WITH x AS (DELETE ...)) or DROP/TRUNCATE/INSERT ran without it.
  Every statement that is not a plain read now needs confirmation,
  decided on the server.
- occ output could contain escape sequences, backspaces and URLs from
  user-controlled text (display names, file names) that rewrite or hide
  text or show a clickable link. Only color codes are kept now, and
  URLs are no longer turned into links.

The checks move to SqlGuard so they can be tested on their own. They
remain defense in depth: dynamic SQL can always get around a text
check, so the database role should not have superuser rights,
pg_execute_server_program or pg_read/write_server_files. A read that
starts with WITH now shows its rows like a SELECT.
2026-09-30 03:15:35 +02:00

129 lines
4.2 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(''));
}
}