Require explicit confirmation before running DELETE statements in SQL mode

This commit is contained in:
Egor Bugaev
2026-07-06 17:03:51 +03:00
parent 07df21aac9
commit 4d87deb176
2 changed files with 91 additions and 17 deletions
+51 -13
View File
@@ -14,6 +14,16 @@
var OCC_PROMPT = 'occ $ '; var OCC_PROMPT = 'occ $ ';
var SQL_PROMPT = '[[;#ff5555;]sql]# '; var SQL_PROMPT = '[[;#ff5555;]sql]# ';
// Быстрая клиентская проверка на DELETE — только для UX (чтобы не
// делать лишний запрос к серверу). Итоговое решение всё равно
// принимает бэкенд (requiresConfirmation), это лишь подсказка.
function scriptHasDelete(sql) {
return sql.split(';').some(function (part) {
var normalized = part.replace(/^(\s*--[^\n]*\n)*\s*/, '');
return /^DELETE\b/i.test(normalized);
});
}
function renderSqlResponse(term, response) { function renderSqlResponse(term, response) {
if (!response) { if (!response) {
term.echo('[[;#ff5555;]Empty response from server]'); term.echo('[[;#ff5555;]Empty response from server]');
@@ -39,6 +49,8 @@
} }
} else if (r.type === 'set') { } else if (r.type === 'set') {
term.echo('[[;green;] OK (session variable set)]'); term.echo('[[;green;] OK (session variable set)]');
} else if (r.type === 'delete') {
term.echo('[[;#ff9900;] DELETED ' + r.affected_rows + ' row(s)]');
} else { } else {
term.echo('[[;green;] OK, ' + r.affected_rows + ' row(s) affected]'); term.echo('[[;green;] OK, ' + r.affected_rows + ' row(s) affected]');
} }
@@ -58,6 +70,40 @@
term.echo('[[;yellow;]Switched back to OCC mode.]'); term.echo('[[;yellow;]Switched back to OCC mode.]');
} }
function sendSqlQuery(term, sql, confirmed) {
term.pause();
$.ajax({
url: baseUrl + '/db/query',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ sql: sql, confirm: !!confirmed })
}).done(function (response) {
if (response && response.requiresConfirmation) {
term.resume();
askDeleteConfirmation(term, sql, response.error);
return;
}
renderSqlResponse(term, response);
term.resume();
}).fail(function (xhr) {
term.echo('[[;#ff5555;]Request failed: ]' + $.terminal.escape_formatting(xhr.status + ' ' + xhr.statusText));
term.resume();
});
}
function askDeleteConfirmation(term, sql, message) {
var prompt = '[[;#ff5555;]' + (message || 'This script contains DELETE statement(s).') + ' Type "yes" to run it: ]';
term.read(prompt).then(function (answer) {
if ((answer || '').trim().toLowerCase() === 'yes') {
sendSqlQuery(term, sql, true);
} else {
term.echo('[[;yellow;]Cancelled — nothing was executed.]');
}
}, function () {
term.echo('[[;yellow;]Cancelled — nothing was executed.]');
});
}
$.get(baseUrl + '/cmd', function(response){ $.get(baseUrl + '/cmd', function(response){
$('#app-content').terminal(function(command, term) { $('#app-content').terminal(function(command, term) {
if (mode === 'sql') { if (mode === 'sql') {
@@ -78,19 +124,11 @@
if (!trimmed) { if (!trimmed) {
return; return;
} }
term.pause(); if (scriptHasDelete(command)) {
$.ajax({ askDeleteConfirmation(term, command);
url: baseUrl + '/db/query', } else {
type: 'POST', sendSqlQuery(term, command, false);
contentType: 'application/json', }
data: JSON.stringify({ sql: command })
}).done(function (response) {
renderSqlResponse(term, response);
term.resume();
}).fail(function (xhr) {
term.echo('[[;#ff5555;]Request failed: ]' + $.terminal.escape_formatting(xhr.status + ' ' + xhr.statusText));
term.resume();
});
return; return;
} }
+40 -4
View File
@@ -28,6 +28,20 @@ class DbController extends Controller
$this->userSession = $userSession; $this->userSession = $userSession;
} }
/**
* Убирает ведущие однострочные комментарии ("-- ...") перед запросом.
* После разбиения пачки по ";" такой комментарий может "приклеиться"
* к следующему запросу и помешать определить его тип (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;
}
/** /**
* @NoCSRFRequired * @NoCSRFRequired
*/ */
@@ -38,12 +52,13 @@ class DbController extends Controller
if (!$user) { if (!$user) {
return new JSONResponse(['error' => 'Not authenticated'], 401); return new JSONResponse(['error' => 'Not authenticated'], 401);
} }
if (!$this->groupManager->isAdmin($user->getUID())) { if (!$this->groupManager->isAdmin($user->getUID())) {
return new JSONResponse(['error' => 'Admin privileges required'], 403); return new JSONResponse(['error' => 'Admin privileges required'], 403);
} }
$sql = $this->request->getParam('sql', ''); $sql = $this->request->getParam('sql', '');
$confirmed = filter_var($this->request->getParam('confirm', false), FILTER_VALIDATE_BOOLEAN);
if (empty(trim($sql))) { if (empty(trim($sql))) {
return new JSONResponse(['success' => false, 'error' => 'Empty query']); return new JSONResponse(['success' => false, 'error' => 'Empty query']);
@@ -57,11 +72,31 @@ class DbController extends Controller
return $q !== ''; return $q !== '';
})); }));
// DELETE необратим, поэтому требуем явное подтверждение с клиента
// (confirm=true), прежде чем выполнять хоть один запрос из пачки.
$deleteCount = 0;
foreach ($queries as $query) {
if (stripos($this->stripLeadingComments($query), 'DELETE') === 0) {
$deleteCount++;
}
}
if ($deleteCount > 0 && !$confirmed) {
return new JSONResponse([
'success' => false,
'requiresConfirmation' => true,
'deleteCount' => $deleteCount,
'error' => "Batch contains {$deleteCount} DELETE statement(s) and was not executed. Resend with confirm=true to proceed."
]);
}
$results = []; $results = [];
foreach ($queries as $query) { foreach ($queries as $query) {
$isSelect = stripos($query, 'SELECT') === 0; $normalized = $this->stripLeadingComments($query);
$isSet = stripos($query, 'SET ') === 0; $isSelect = stripos($normalized, 'SELECT') === 0;
$isSet = stripos($normalized, 'SET ') === 0;
$isDelete = stripos($normalized, 'DELETE') === 0;
try { try {
$stmt = $this->db->prepare($query); $stmt = $this->db->prepare($query);
@@ -77,9 +112,10 @@ class DbController extends Controller
]; ];
} else { } else {
$affected = $stmt->rowCount(); $affected = $stmt->rowCount();
$type = $isSet ? 'set' : ($isDelete ? 'delete' : 'write');
$results[] = [ $results[] = [
'query' => $query, 'query' => $query,
'type' => $isSet ? 'set' : 'write', 'type' => $type,
'affected_rows' => $affected 'affected_rows' => $affected
]; ];
} }