diff --git a/appinfo/info.xml b/appinfo/info.xml index c486531..22accd0 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ OCC Web OCC Commands in a web terminal - 0.2.5 + 0.2.6 agpl Adphi OCCWeb diff --git a/js/index.js b/js/index.js index bd12535..bf456ea 100644 --- a/js/index.js +++ b/js/index.js @@ -14,11 +14,43 @@ var OCC_PROMPT = 'occ $ '; var SQL_PROMPT = '[[;#ff5555;]sql]# '; + // Разбивает пачку запросов по ";" с учётом одинарных кавычек (в т.ч. '' + // как экранированной кавычки внутри строки), чтобы ";" в строковом + // литерале не ломал разбиение. Зеркалит splitStatements() на бэкенде. + function splitStatements(sql) { + var statements = []; + var current = ''; + var inString = false; + for (var i = 0; i < sql.length; i++) { + var ch = sql[i]; + if (ch === "'") { + if (inString && sql[i + 1] === "'") { + current += "''"; + i++; + continue; + } + inString = !inString; + current += ch; + continue; + } + if (ch === ';' && !inString) { + statements.push(current.trim()); + current = ''; + continue; + } + current += ch; + } + if (current.trim() !== '') { + statements.push(current.trim()); + } + return statements.filter(function (s) { return s !== ''; }); + } + // Быстрая клиентская проверка на DELETE — только для UX (чтобы не // делать лишний запрос к серверу). Итоговое решение всё равно // принимает бэкенд (requiresConfirmation), это лишь подсказка. function scriptHasDelete(sql) { - return sql.split(';').some(function (part) { + return splitStatements(sql).some(function (part) { var normalized = part.replace(/^(\s*--[^\n]*\n)*\s*/, ''); return /^DELETE\b/i.test(normalized); }); @@ -160,6 +192,9 @@ if (r.count > 0) { renderTable(term, r.data); } + if (r.truncated) { + term.echo('[[;yellow;] Result truncated — showing only the first ' + r.count + ' rows, add LIMIT to see more precisely.]'); + } } else if (r.type === 'set') { term.echo('[[;green;] OK (session variable set)]'); } else if (r.type === 'delete') { diff --git a/lib/Controller/DbController.php b/lib/Controller/DbController.php index edad487..e1febb8 100644 --- a/lib/Controller/DbController.php +++ b/lib/Controller/DbController.php @@ -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 {