Harden SQL mode: block file/program access, audit log, row limit, quote-aware statement splitting

This commit is contained in:
Egor Bugaev
2026-07-06 17:54:36 +03:00
parent d4d1fbcbfc
commit 8aaab998a1
3 changed files with 174 additions and 13 deletions
+137 -11
View File
@@ -2,22 +2,48 @@
namespace OCA\OCCWeb\Controller;
use OC;
use OCP\AppFramework\Controller;
use OCP\IRequest;
use OCP\IDBConnection;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IGroupManager;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
class DbController extends Controller
{
/** Максимум строк, возвращаемых на один SELECT (защита от OOM/DoS). */
private const MAX_ROWS = 1000;
/**
* Конструкции, дающие доступ к файловой системе сервера или запуску
* внешних программ через SQL. Блокируются полностью, без возможности
* подтверждения — в отличие от DELETE, это не про потерю данных,
* а про потенциальный захват сервера.
*/
private const FORBIDDEN_PATTERNS = [
'/\bCOPY\b[\s\S]*\bPROGRAM\b/i' => 'COPY ... PROGRAM (запуск внешних команд)',
'/\bCOPY\b[\s\S]*\b(FROM|TO)\b\s*\'/i' => "COPY ... FROM/TO 'file' (доступ к файловой системе сервера)",
'/\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() (подключение к произвольным БД)',
'/\bLOAD_FILE\s*\(/i' => 'LOAD_FILE()',
'/\bINTO\s+(OUTFILE|DUMPFILE)\b/i' => 'INTO OUTFILE/DUMPFILE',
];
private $db;
private $groupManager;
private $userSession;
private $logger;
public function __construct(
$AppName,
IRequest $request,
$AppName,
IRequest $request,
IDBConnection $db,
IGroupManager $groupManager,
IUserSession $userSession
@@ -26,11 +52,14 @@ class DbController extends Controller
$this->db = $db;
$this->groupManager = $groupManager;
$this->userSession = $userSession;
// Через OC::$server, как и в OccController — не добавляем LoggerInterface
// в конструктор, чтобы не менять сигнатуру, резолвящуюся DI-контейнером.
$this->logger = OC::$server->get(LoggerInterface::class);
}
/**
* Убирает ведущие однострочные комментарии ("-- ...") перед запросом.
* После разбиения пачки по ";" такой комментарий может "приклеиться"
* После разбиения пачки такой комментарий может "приклеиться"
* к следующему запросу и помешать определить его тип (SELECT/SET/DELETE).
*/
private function stripLeadingComments($query)
@@ -42,6 +71,64 @@ class DbController extends Controller
return $query;
}
/**
* Разбивает пачку запросов по ";" с учётом одинарных кавычек, чтобы
* точка с запятой внутри строкового литерала (в том числе с '' как
* экранированной кавычкой) не ломала разбиение.
*/
private function splitStatements($sql)
{
$statements = [];
$current = '';
$len = strlen($sql);
$inString = false;
for ($i = 0; $i < $len; $i++) {
$ch = $sql[$i];
if ($ch === "'") {
if ($inString && $i + 1 < $len && $sql[$i + 1] === "'") {
$current .= "''";
$i++;
continue;
}
$inString = !$inString;
$current .= $ch;
continue;
}
if ($ch === ';' && !$inString) {
$statements[] = trim($current);
$current = '';
continue;
}
$current .= $ch;
}
if (trim($current) !== '') {
$statements[] = trim($current);
}
return array_values(array_filter($statements, function ($s) {
return $s !== '';
}));
}
/**
* Возвращает описание найденной запрещённой конструкции (доступ к ФС,
* запуск программ) или null, если запрос безопасен в этом плане.
*/
private function findForbiddenConstruct($query)
{
foreach (self::FORBIDDEN_PATTERNS as $pattern => $label) {
if (preg_match($pattern, $query)) {
return $label;
}
}
return null;
}
/**
* @NoCSRFRequired
*/
@@ -64,13 +151,40 @@ class DbController extends Controller
return new JSONResponse(['success' => false, 'error' => 'Empty query']);
}
// Разделяем запросы по точке с запятой. Все запросы выполняются
// последовательно на одном и том же соединении с БД (в рамках
// одного HTTP-запроса), поэтому SET сохраняет своё значение
// для current_setting() в последующих запросах пачки.
$queries = array_values(array_filter(array_map('trim', explode(';', $sql)), function ($q) {
return $q !== '';
}));
// Аудит: логируем сам факт попытки выполнения ДО всех проверок,
// чтобы в логе остались и заблокированные/отклонённые запросы,
// а не только успешно выполненные.
$this->logger->warning('[occweb] SQL submitted by {user}: {sql}', [
'app' => 'occweb',
'user' => $user->getUID(),
'sql' => $sql,
]);
// Разбиваем пачку по ";" с учётом кавычек (см. splitStatements).
// Все запросы выполняются последовательно на одном и том же
// соединении с БД (в рамках одного HTTP-запроса), поэтому SET
// сохраняет своё значение для current_setting() в следующих
// запросах этой же пачки.
$queries = $this->splitStatements($sql);
// Доступ к файловой системе сервера / запуск программ через SQL
// блокируется полностью — это не про потерю данных (как DELETE),
// а про потенциальный захват сервера, подтверждением не обходится.
foreach ($queries as $query) {
$forbidden = $this->findForbiddenConstruct($query);
if ($forbidden !== null) {
$this->logger->error('[occweb] Blocked forbidden construct ({construct}) from {user}: {sql}', [
'app' => 'occweb',
'construct' => $forbidden,
'user' => $user->getUID(),
'sql' => $sql,
]);
return new JSONResponse([
'success' => false,
'error' => "Query blocked: contains a forbidden construct ({$forbidden}). File/program access via SQL is not allowed."
]);
}
}
// DELETE необратим, поэтому требуем явное подтверждение с клиента
// (confirm=true), прежде чем выполнять хоть один запрос из пачки.
@@ -103,11 +217,23 @@ class DbController extends Controller
$stmt->execute();
if ($isSelect) {
$rows = $stmt->fetchAll();
// Читаем построчно и останавливаемся на MAX_ROWS, а не
// fetchAll() + array_slice — иначе SELECT без LIMIT на
// огромной таблице всё равно утащит всё в память PHP.
$rows = [];
$truncated = false;
while (($row = $stmt->fetch()) !== false) {
if (count($rows) >= self::MAX_ROWS) {
$truncated = true;
break;
}
$rows[] = $row;
}
$results[] = [
'query' => $query,
'type' => 'select',
'count' => count($rows),
'truncated' => $truncated,
'data' => $rows
];
} else {