Files
occweb/lib/Controller/DbController.php
T
dillard f89ced651b Show rows for all reads and confirm nextval()
SHOW, EXPLAIN, VALUES and TABLE count as reads now but their rows were
dropped and reported as a write; every read except SET now returns its
rows. nextval() advances a sequence, so it needs confirmation like
setval().
2026-09-30 03:59:11 +02:00

277 lines
12 KiB
PHP

<?php
namespace OCA\OCCWeb\Controller;
use OC;
use OCA\OCCWeb\Service\SqlGuard;
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
{
/** Maximum rows returned per SELECT (protection against OOM/DoS). */
private const MAX_ROWS = 1000;
/** Maximum size of submitted SQL text in bytes (protection against OOM/DoS). */
private const MAX_SQL_BYTES = 1048576; // 1 MB
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;
// Via OC::$server, same as in OccController — we don't add LoggerInterface
// to the constructor so as not to change the signature resolved by the DI container.
$this->logger = OC::$server->get(LoggerInterface::class);
}
public function query()
{
// Deliberate defense-in-depth (see the equivalent check in
// OccController::requireAdmin() for why it's kept even though
// Nextcloud's SecurityMiddleware already enforces this by default
// absent @NoAdminRequired) - this one is also the direct fix for
// this controller having shipped with @PublicPage originally,
// which did bypass that default protection entirely.
$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);
}
// Audit: log the fact that execution was attempted BEFORE any checks,
// so blocked/rejected queries are recorded in the log too, not just
// the ones that succeeded.
$this->logger->warning('[extended_occweb] SQL submitted by {user}: {sql}', [
'app' => 'extended_occweb',
'user' => $user->getUID(),
'sql' => $sql,
]);
// Split the batch on ";" respecting quotes (see splitStatements).
// All queries run sequentially on the same DB connection (within a
// single HTTP request), so SET retains its value for
// current_setting() in the following queries of the same batch.
$queries = SqlGuard::splitStatements($sql);
// Access to the server's filesystem / running programs via SQL is
// blocked entirely — this isn't about data loss (like DELETE), it's
// about potential server takeover, and confirmation doesn't bypass it.
foreach ($queries as $query) {
$forbidden = SqlGuard::findForbiddenConstruct($query);
if ($forbidden !== null) {
$this->logger->error('[extended_occweb] Blocked forbidden construct ({construct}) from {user}: {sql}', [
'app' => 'extended_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."
]);
}
}
// Anything that isn't a plain read (DELETE, UPDATE, INSERT, DROP,
// a data-modifying CTE, ...) needs explicit confirmation from the
// client (confirm=true) before a single query of the batch runs.
$writeTypes = [];
foreach ($queries as $query) {
if (!SqlGuard::isReadOnly($query)) {
$type = SqlGuard::statementType($query) ?: 'UNKNOWN';
$writeTypes[$type] = ($writeTypes[$type] ?? 0) + 1;
}
}
if ($writeTypes !== [] && !$confirmed) {
$parts = [];
foreach ($writeTypes as $type => $count) {
$parts[] = "{$count} {$type}";
}
return new JSONResponse([
'success' => false,
'requiresConfirmation' => true,
'deleteCount' => $writeTypes['DELETE'] ?? 0,
'updateCount' => $writeTypes['UPDATE'] ?? 0,
'writeCount' => array_sum($writeTypes),
'error' => 'Batch contains statement(s) that change data or the schema (' . implode(', ', $parts) . ') and was not executed. Resend with confirm=true to proceed.'
]);
}
// The batch runs inside a single transaction: if one of the queries
// fails (e.g. a DELETE partway through a series due to an FK), all
// changes already made within this same batch are rolled back instead
// of staying partially applied. SET (without LOCAL) isn't
// transactional in PostgreSQL, so the rollback doesn't affect
// current_setting().
//
// About DDL: in PostgreSQL (our database) DDL — CREATE/ALTER/DROP
// TABLE etc. — is fully transactional and rolls back together with
// the rest of the batch's changes, so there's no problem here for
// Postgres. But Nextcloud also works with MySQL/MariaDB through the
// same IDBConnection, and there DDL causes an implicit COMMIT — if
// this batch runs on a MySQL instance, commit()/rollBack() after such
// a DDL statement will get a "no active transaction" exception. We
// wrap begin/commit/rollBack in try/catch so this doesn't turn into
// an unhandled exception and an HTTP 500 instead of a clean JSON
// response.
$results = [];
$rolledBack = false;
$rollbackFailed = false;
$transactionStarted = false;
try {
$this->db->beginTransaction();
$transactionStarted = true;
} catch (\Exception $e) {
$this->logger->warning('[extended_occweb] beginTransaction() failed, proceeding without an explicit transaction: {error}', [
'app' => 'extended_occweb',
'error' => $e->getMessage(),
]);
}
foreach ($queries as $query) {
$statementType = SqlGuard::statementType($query);
$isSet = $statementType === 'SET';
$isDelete = $statementType === 'DELETE';
$isUpdate = $statementType === 'UPDATE';
try {
$stmt = $this->db->prepare($query);
$stmt->execute();
if ($statementType === 'SELECT' || ($statementType !== 'SET' && SqlGuard::isReadOnly($query))) {
// Read row by row and stop at MAX_ROWS, instead of
// fetchAll() + array_slice — otherwise a SELECT without
// LIMIT on a huge table would still pull everything into
// PHP memory.
$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()
];
// Roll back the whole batch and stop: don't keep executing
// the remaining queries (e.g. a series of DELETEs) if one of
// the preceding steps failed.
if ($transactionStarted) {
try {
$this->db->rollBack();
$rolledBack = true;
} catch (\Exception $rollbackError) {
// The transaction was already closed by something
// other than us — e.g. an implicit COMMIT from a DDL
// statement on MySQL/MariaDB. Anything that executed
// BEFORE this point in the batch may remain
// permanently applied — we deliberately do NOT set
// rolledBack to true, since that would be untrue.
$rollbackFailed = true;
$this->logger->warning('[extended_occweb] rollBack() failed after an error — earlier statements in this batch may already be permanently applied: {error}', [
'app' => 'extended_occweb',
'error' => $rollbackError->getMessage(),
]);
}
}
break;
}
}
if (!$rolledBack && !$rollbackFailed && $transactionStarted) {
try {
$this->db->commit();
} catch (\Exception $e) {
// The transaction was already committed implicitly (e.g. by
// a DDL statement on MySQL/MariaDB) — the effects are
// already saved, this isn't an error in executing the batch
// itself.
$this->logger->info('[extended_occweb] commit() had nothing to commit (likely auto-committed by a DDL statement): {error}', [
'app' => 'extended_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);
}
}