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 {