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.
This commit is contained in:
2026-09-30 03:32:58 +02:00
parent f68b1658be
commit f4b7484e2f
4 changed files with 97 additions and 55 deletions
+70
View File
@@ -49,6 +49,76 @@ class SqlGuard
'/\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