Files
Egor Bugaev 9e15c3b96c Add rename-user SQL template; make batch transaction handling robust against non-Postgres backends
- New 'rename-user' template (templates / template rename-user <old> <new>):
  best-effort uid rename across core tables (oc_users, oc_preferences,
  oc_group_user, oc_group_admin, oc_ldap_user_mapping, oc_share, oc_mounts,
  oc_storages). Explicitly NOT a supported Nextcloud operation - the
  generated script carries an in-line warning (as leading SQL comments)
  that app-specific tables (Talk, Calendar, Contacts, Mail, 2FA/WebAuthn...)
  are not covered and that the data directory must be renamed on disk
  manually, followed by occ files:scan --all. Documented the same caveat
  in both READMEs.
- Hardened the round-2 transaction wrapping: beginTransaction()/commit()/
  rollBack() are now wrapped in try/catch. PostgreSQL (our backend) has
  fully transactional DDL so this wasn't actually broken here, but
  Nextcloud also supports MySQL/MariaDB via the same IDBConnection, where
  DDL implicitly commits - on that backend a DDL statement in the batch
  would make a later commit()/rollBack() throw 'no active transaction'
  and previously that exception was unhandled (HTTP 500 instead of a
  clean JSON response). Now: if rollBack() itself fails after an error,
  we report rollbackFailed+warning instead of falsely claiming rolledBack,
  since earlier statements in that batch may already be permanently
  applied. If commit() fails with nothing to commit (already
  auto-committed), that's logged only, since the effects are already
  durably persisted.
2026-07-06 19:52:05 +03:00

395 lines
18 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
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-текста в байтах (защита от OOM/DoS). */
private const MAX_SQL_BYTES = 1048576; // 1 MB
/**
* Конструкции, дающие доступ к файловой системе сервера или запуску
* внешних программ через 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,
IDBConnection $db,
IGroupManager $groupManager,
IUserSession $userSession
) {
parent::__construct($AppName, $request);
$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)
{
$query = ltrim($query);
while (preg_match('/^--[^\n]*\n/', $query)) {
$query = ltrim(preg_replace('/^--[^\n]*\n/', '', $query, 1));
}
return $query;
}
/**
* Убирает ВСЕ однострочные (-- ...) и блочные C-style комментарии
* из запроса, включая те, что стоят внутри выражения (например, между
* именем функции и открывающей скобкой — иначе так можно спрятать
* запрещённую конструкцию от findForbiddenConstruct). Используется
* только для проверки на запрещённые конструкции — сам запрос на
* выполнение идёт без изменений.
*/
private function removeAllComments($query)
{
$query = preg_replace('/--[^\n]*/', '', $query);
$query = preg_replace('/\/\*[\s\S]*?\*\//', '', $query);
return $query;
}
/**
* Разбивает пачку запросов по ";" с учётом одинарных и двойных
* кавычек (строки и экранированные идентификаторы Postgres), чтобы
* точка с запятой внутри 'строки' или "идентификатора" (в том числе
* с '' / "" как экранированной кавычкой) не ломала разбиение.
*/
private function splitStatements($sql)
{
$statements = [];
$current = '';
$len = strlen($sql);
$quoteChar = null;
for ($i = 0; $i < $len; $i++) {
$ch = $sql[$i];
if ($quoteChar !== null) {
if ($ch === $quoteChar) {
if ($i + 1 < $len && $sql[$i + 1] === $quoteChar) {
$current .= $quoteChar . $quoteChar;
$i++;
continue;
}
$quoteChar = null;
}
$current .= $ch;
continue;
}
if ($ch === "'" || $ch === '"') {
$quoteChar = $ch;
$current .= $ch;
continue;
}
if ($ch === ';') {
$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)
{
$clean = $this->removeAllComments($query);
foreach (self::FORBIDDEN_PATTERNS as $pattern => $label) {
if (preg_match($pattern, $clean)) {
return $label;
}
}
return null;
}
/**
* @NoCSRFRequired
*/
public function query()
{
// Проверка прав администратора
$user = $this->userSession->getUser();
if (!$user) {
return new JSONResponse(['error' => 'Not authenticated'], 401);
}
if (!$this->groupManager->isAdmin($user->getUID())) {
return new JSONResponse(['error' => 'Admin privileges required'], 403);
}
$sql = $this->request->getParam('sql', '');
$confirmed = filter_var($this->request->getParam('confirm', false), FILTER_VALIDATE_BOOLEAN);
if (empty(trim($sql))) {
return new JSONResponse(['success' => false, 'error' => 'Empty query']);
}
if (strlen($sql) > self::MAX_SQL_BYTES) {
return new JSONResponse([
'success' => false,
'error' => 'Query too large (max ' . self::MAX_SQL_BYTES . ' bytes)'
], 413);
}
// Аудит: логируем сам факт попытки выполнения ДО всех проверок,
// чтобы в логе остались и заблокированные/отклонённые запросы,
// а не только успешно выполненные.
$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 и UPDATE необратимы (или трудно обратимы), поэтому
// требуем явное подтверждение с клиента (confirm=true), прежде
// чем выполнять хоть один запрос из пачки.
$deleteCount = 0;
$updateCount = 0;
foreach ($queries as $query) {
$normalized = $this->stripLeadingComments($query);
if (stripos($normalized, 'DELETE') === 0) {
$deleteCount++;
} elseif (stripos($normalized, 'UPDATE') === 0) {
$updateCount++;
}
}
if (($deleteCount > 0 || $updateCount > 0) && !$confirmed) {
$parts = [];
if ($deleteCount > 0) {
$parts[] = "{$deleteCount} DELETE";
}
if ($updateCount > 0) {
$parts[] = "{$updateCount} UPDATE";
}
return new JSONResponse([
'success' => false,
'requiresConfirmation' => true,
'deleteCount' => $deleteCount,
'updateCount' => $updateCount,
'error' => 'Batch contains ' . implode(' and ', $parts) . ' statement(s) and was not executed. Resend with confirm=true to proceed.'
]);
}
// Пачка выполняется в одной транзакции: если один из запросов
// упадёт (например, DELETE на середине серии из-за FK), все уже
// выполненные в этой же пачке изменения откатываются, а не
// остаются частично применёнными. SET (без LOCAL) не транзакционен
// в PostgreSQL, поэтому откат не затрагивает current_setting().
//
// Про DDL: в PostgreSQL (наша БД) DDL — CREATE/ALTER/DROP TABLE и
// т.п. — полностью транзакционен и откатывается вместе с остальными
// изменениями пачки, поэтому здесь для Postgres проблемы нет. Но
// Nextcloud работает и с MySQL/MariaDB через тот же IDBConnection, а
// там DDL делает неявный COMMIT — если эта пачка выполнится на
// MySQL-инстансе, commit()/rollBack() после такого DDL получат
// исключение "no active transaction". Оборачиваем begin/commit/
// rollBack в try/catch, чтобы это не превращалось в необработанное
// исключение и HTTP 500 вместо аккуратного JSON-ответа.
$results = [];
$rolledBack = false;
$rollbackFailed = false;
$transactionStarted = false;
try {
$this->db->beginTransaction();
$transactionStarted = true;
} catch (\Exception $e) {
$this->logger->warning('[occweb] beginTransaction() failed, proceeding without an explicit transaction: {error}', [
'app' => 'occweb',
'error' => $e->getMessage(),
]);
}
foreach ($queries as $query) {
$normalized = $this->stripLeadingComments($query);
$isSelect = stripos($normalized, 'SELECT') === 0;
$isSet = stripos($normalized, 'SET ') === 0;
$isDelete = stripos($normalized, 'DELETE') === 0;
$isUpdate = stripos($normalized, 'UPDATE') === 0;
try {
$stmt = $this->db->prepare($query);
$stmt->execute();
if ($isSelect) {
// Читаем построчно и останавливаемся на 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 {
$affected = $stmt->rowCount();
if ($isSet) {
$type = 'set';
} elseif ($isDelete) {
$type = 'delete';
} elseif ($isUpdate) {
$type = 'update';
} else {
$type = 'write';
}
$results[] = [
'query' => $query,
'type' => $type,
'affected_rows' => $affected
];
}
} catch (\Exception $e) {
$results[] = [
'query' => $query,
'type' => 'error',
'error' => $e->getMessage()
];
// Откатываем всю пачку и останавливаемся: не продолжаем
// выполнять оставшиеся запросы (например, серию DELETE),
// если один из предыдущих шагов не выполнился.
if ($transactionStarted) {
try {
$this->db->rollBack();
$rolledBack = true;
} catch (\Exception $rollbackError) {
// Транзакция уже закрыта не нами — например, неявным
// COMMIT-ом от DDL-запроса на MySQL/MariaDB. Всё, что
// выполнилось ДО этой точки в пачке, могло остаться
// применённым навсегда — намеренно НЕ ставим
// rolledBack в true, это было бы неправдой.
$rollbackFailed = true;
$this->logger->warning('[occweb] rollBack() failed after an error — earlier statements in this batch may already be permanently applied: {error}', [
'app' => 'occweb',
'error' => $rollbackError->getMessage(),
]);
}
}
break;
}
}
if (!$rolledBack && !$rollbackFailed && $transactionStarted) {
try {
$this->db->commit();
} catch (\Exception $e) {
// Транзакция уже закоммичена неявно (например, DDL-запросом
// на MySQL/MariaDB) — эффекты уже сохранены, это не ошибка
// выполнения самой пачки.
$this->logger->info('[occweb] commit() had nothing to commit (likely auto-committed by a DDL statement): {error}', [
'app' => 'occweb',
'error' => $e->getMessage(),
]);
}
}
$response = [
'success' => true,
'rolledBack' => $rolledBack,
'results' => $results
];
if ($rollbackFailed) {
$response['rollbackFailed'] = true;
$response['warning'] = 'The batch failed partway through and the rollback itself failed (this can happen if an earlier '
. 'statement, e.g. a DDL statement, implicitly committed on this database backend). Statements executed before the '
. 'failure may have been permanently applied — check the results above and verify manually.';
}
return new JSONResponse($response);
}
}