Files
occweb/lib/Service/SqlGuard.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

186 lines
7.1 KiB
PHP

<?php
namespace OCA\OCCWeb\Service;
/**
* Checks run on SQL submitted through the SQL mode before it is executed.
*
* This is defense in depth, not a sandbox: dynamic SQL (EXECUTE with a
* built-up string, functions) can always get around a text-based check.
* The real protection is a database role without superuser rights,
* pg_execute_server_program or pg_read/write_server_files.
*/
class SqlGuard
{
/**
* Constructs that give access to the server's filesystem or allow
* launching external programs via SQL. These are blocked entirely,
* with no way to confirm past them.
*/
private const FORBIDDEN_PATTERNS = [
'/\bCOPY\b[\s\S]*\bPROGRAM\b/i' => 'COPY ... PROGRAM (runs external commands)',
// A COPY file name is a string literal: 'x', E'x', U&'x' or $$x$$
'/\bCOPY\b[\s\S]*\b(FROM|TO)\s+(E|U&)?(\'|\$)/i' => "COPY ... FROM/TO 'file' (access to the server's filesystem)",
'/\bpg_read_binary_file\s*\(/i' => 'pg_read_binary_file()',
'/\bpg_read_file\s*\(/i' => 'pg_read_file()',
'/\bpg_ls_dir\s*\(/i' => 'pg_ls_dir()',
'/\bpg_stat_file\s*\(/i' => 'pg_stat_file()',
'/\blo_import\s*\(/i' => 'lo_import()',
'/\blo_export\s*\(/i' => 'lo_export()',
'/\bdblink(_connect)?\s*\(/i' => 'dblink() (connects to arbitrary databases)',
'/\bLOAD_FILE\s*\(/i' => 'LOAD_FILE()',
'/\bINTO\s+(OUTFILE|DUMPFILE)\b/i' => 'INTO OUTFILE/DUMPFILE',
];
/** Statements that start with one of these only read, unless WRITES_INSIDE_READ matches. */
private const READ_ONLY_START = '/^(SELECT|WITH|SHOW|EXPLAIN|SET|VALUES|TABLE)\b/i';
/**
* What makes a statement that starts as a read change something:
* a data-modifying CTE (WITH x AS (DELETE ...)), SELECT ... INTO,
* SELECT ... FOR UPDATE, EXPLAIN ANALYZE (runs the statement),
* SET ROLE / SET SESSION AUTHORIZATION, and functions with side
* effects. Statements that don't start as a read always need
* confirmation, so INSERT, DROP, CREATE etc. don't need to be here.
*/
private const WRITES_INSIDE_READ = [
'/\b(INSERT|UPDATE|DELETE|MERGE|INTO|ANALYZE)\b/i',
'/^SET\s+(SESSION\s+|LOCAL\s+)?(ROLE|SESSION\s+AUTHORIZATION)\b/i',
'/\b(pg_terminate_backend|pg_cancel_backend|pg_reload_conf|set_config|setval|lo_create|lo_put|lo_unlink|pg_switch_wal|pg_create_restore_point|pg_advisory_lock)\s*\(/i',
];
/**
* Splits a batch on ";" outside of 'strings', "identifiers",
* $tag$dollar-quoted bodies$tag$ and comments, so a semicolon in a
* DO block or a quote in a comment doesn't break up the split.
*
* @return string[]
*/
public static function splitStatements(string $sql): array
{
$statements = [];
$current = '';
$len = strlen($sql);
$i = 0;
while ($i < $len) {
$ch = $sql[$i];
$end = null;
if ($ch === "'" || $ch === '"') {
$backslashEscapes = $ch === "'" && $i > 0 && ($sql[$i - 1] === 'E' || $sql[$i - 1] === 'e')
&& ($i === 1 || !preg_match('/[A-Za-z0-9_]/', $sql[$i - 2]));
$end = $i + 1;
while ($end < $len) {
if ($backslashEscapes && $sql[$end] === '\\') {
$end += 2;
continue;
}
if ($sql[$end] === $ch) {
if ($end + 1 < $len && $sql[$end + 1] === $ch) {
$end += 2;
continue;
}
break;
}
$end++;
}
$end = min($end + 1, $len);
} elseif ($ch === '$' && preg_match('/\G\$([A-Za-z_][A-Za-z0-9_]*)?\$/', $sql, $m, 0, $i)) {
$close = strpos($sql, $m[0], $i + strlen($m[0]));
$end = $close === false ? $len : $close + strlen($m[0]);
} elseif ($ch === '-' && substr($sql, $i, 2) === '--') {
$newline = strpos($sql, "\n", $i);
$end = $newline === false ? $len : $newline;
} elseif ($ch === '/' && substr($sql, $i, 2) === '/*') {
$close = strpos($sql, '*/', $i + 2);
$end = $close === false ? $len : $close + 2;
}
if ($end !== null) {
$current .= substr($sql, $i, $end - $i);
$i = $end;
continue;
}
if ($ch === ';') {
$statements[] = trim($current);
$current = '';
} else {
$current .= $ch;
}
$i++;
}
$statements[] = trim($current);
return array_values(array_filter($statements, function ($s) {
return $s !== '';
}));
}
/**
* Replaces comments with a space. A comment separates tokens, so
* dropping it without a space would glue "TO/ ** /PROGRAM" into one
* word and hide it from the patterns above.
*/
public static function removeComments(string $query): string
{
$query = preg_replace('/--[^\n]*/', ' ', $query);
return preg_replace('/\/\*[\s\S]*?\*\//', ' ', $query);
}
/**
* Replaces string literals and quoted identifiers with a placeholder,
* so words inside them ('deleted', "update") don't count as keywords.
*/
public static function removeLiterals(string $query): string
{
$query = preg_replace('/\$([A-Za-z_][A-Za-z0-9_]*|)\$[\s\S]*?\$\1\$/', "''", $query);
$query = preg_replace("/[Ee]'(?:[^'\\\\]|\\\\.|'')*'/", "''", $query);
$query = preg_replace("/'(?:[^']|'')*'/", "''", $query);
return preg_replace('/"(?:[^"]|"")*"/', '""', $query);
}
/**
* Returns a description of the forbidden construct found, or null.
* Literals are kept here on purpose: a file name is a literal.
*/
public static function findForbiddenConstruct(string $query): ?string
{
$clean = self::removeComments($query);
foreach (self::FORBIDDEN_PATTERNS as $pattern => $label) {
if (preg_match($pattern, $clean)) {
return $label;
}
}
return null;
}
/**
* The first keyword of a statement, upper case, e.g. SELECT or DELETE.
*/
public static function statementType(string $query): string
{
$clean = ltrim(self::removeComments($query), " \t\n\r\0\x0B(");
return preg_match('/^([A-Za-z]+)/', $clean, $m) ? strtoupper($m[1]) : '';
}
/**
* Whether a statement only reads. Anything else needs confirmation.
*/
public static function isReadOnly(string $query): bool
{
$clean = ltrim(self::removeLiterals(self::removeComments($query)), " \t\n\r\0\x0B(");
if (preg_match(self::READ_ONLY_START, $clean) !== 1) {
return false;
}
foreach (self::WRITES_INSIDE_READ as $pattern) {
if (preg_match($pattern, $clean) === 1) {
return false;
}
}
return true;
}
}