Harden SQL mode: block file/program access, audit log, row limit, quote-aware statement splitting
This commit is contained in:
+1
-1
@@ -5,7 +5,7 @@
|
||||
<name>OCC Web</name>
|
||||
<summary>OCC Commands in a web terminal</summary>
|
||||
<description><![CDATA[Run OCC Commands in a web terminal]]></description>
|
||||
<version>0.2.5</version>
|
||||
<version>0.2.6</version>
|
||||
<licence>agpl</licence>
|
||||
<author mail="adphi.apps@gmail.com" >Adphi</author>
|
||||
<namespace>OCCWeb</namespace>
|
||||
|
||||
+36
-1
@@ -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') {
|
||||
|
||||
+137
-11
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user