mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-11-06 20:53:00 -05:00
dbConnect() turns emulated prepares off, so every query of the web UI, the ajax calls and the includes the API runs (config, servers, users, permissions) is a server-side prepare: prepare, execute and close, three round trips to the database where one would do. From PHP 8.1, pdo_mysql returns integers and floats as PHP ints and floats with emulated prepares too, so values keep the types they have now. Emulate prepares there and keep server-side prepares on older PHP, where every value would become a string. Emulated prepares quote parameters bound as strings, which breaks LIMIT ?. dbQuery() now binds integer values as integers (everything else as strings, as execute() did), and the log and watch ajax calls cast their validated offset and limit to int. With emulation, PDO would also run several statements given in one string; MYSQL_ATTR_MULTI_STATEMENTS is turned off so that is refused as before. CakePHP's own connection is not changed: it already uses PDO's default, emulated prepares. tests/php/test_database_emulated_prepares.php compares every column of every table read through both kinds of prepare (types and values), checks aggregates, LIMIT with positional and named integers, string and null parameters, and that two statements in one string are refused.
305 lines
11 KiB
PHP
305 lines
11 KiB
PHP
<?php
|
|
$data = array();
|
|
$message = '';
|
|
|
|
//
|
|
// INITIALIZE AND CHECK SANITY
|
|
//
|
|
|
|
// task must be set
|
|
if (!isset($_REQUEST['task'])) {
|
|
$message = 'This request requires a task to be set';
|
|
} else if ($_REQUEST['task'] == 'query') {
|
|
if (!canView('System')) {
|
|
$message = 'Insufficient permissions to view log entries for user '.validHtmlStr($user->Username());
|
|
} else {
|
|
$data = queryRequest();
|
|
}
|
|
} else if ($_REQUEST['task'] == 'create' ) {
|
|
global $user;
|
|
if (!$user or (!canEdit('System') and !ZM_LOG_INJECT)) {
|
|
$message = 'Insufficient permissions to create log entries for user '.validHtmlStr($user->Username());
|
|
} else {
|
|
createRequest();
|
|
}
|
|
} else if ($_REQUEST['task'] == 'delete') {
|
|
global $user;
|
|
if (!canEdit('System')) {
|
|
$message = 'Insufficient permissions to delete log entries for user '.validHtmlStr($user->Username());
|
|
} else {
|
|
if (!empty($_REQUEST['ids'])) {
|
|
$ids = array_map('intval', (array)$_REQUEST['ids']);
|
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
|
dbQuery('DELETE FROM Logs WHERE Id IN (' . $placeholders . ')', $ids);
|
|
}
|
|
}
|
|
} else {
|
|
// Only the query and create tasks are supported at the moment
|
|
$message = 'Unrecognised task '.validHtmlStr($_REQUEST['task']);
|
|
}
|
|
|
|
if ($message) {
|
|
ajaxError($message);
|
|
return;
|
|
}
|
|
ajaxResponse($data);
|
|
|
|
//
|
|
// FUNCTION DEFINITIONS
|
|
//
|
|
|
|
function createRequest() {
|
|
// Every field here is attacker-controlled. Coerce each to a scalar and strip
|
|
// control characters (newlines, carriage returns, tabs, etc.) so nothing can
|
|
// forge additional lines in the text log file, and so non-scalar input (e.g.
|
|
// message[]=x) cannot trip a TypeError in logPrint(). A log message is a
|
|
// single line; the Log view stores it as one Message column regardless.
|
|
$sanitize = function($value) {
|
|
return is_scalar($value) ? preg_replace('/[\x00-\x1F\x7F]+/', ' ', (string)$value) : '';
|
|
};
|
|
|
|
$level = $sanitize($_POST['level'] ?? '');
|
|
$message = $sanitize($_POST['message'] ?? '');
|
|
// Strip the URL scheme/host from file, then the control characters.
|
|
$file = (isset($_POST['file']) && is_scalar($_POST['file']))
|
|
? $sanitize(preg_replace('/\w+:\/\/[\w.:]+\//', '', (string)$_POST['file']))
|
|
: '';
|
|
|
|
if ($level === '' || $message === '') {
|
|
ZM\Error('Invalid log create: level and message are required');
|
|
return;
|
|
}
|
|
|
|
ZM\logInit(array('id'=>'web_js'));
|
|
// Firefox reports the location as "line:column" (e.g. 1500:28). validInt()
|
|
// only strips non-digit characters, so it splices the two together into
|
|
// 150028, which overflows the smallint Logs.Line column and makes this
|
|
// endpoint die with a 500 -- the log request fails on the very error it was
|
|
// sent to report. Take the leading line number and clamp it to the column.
|
|
$line = NULL;
|
|
if (!empty($_POST['line']) and is_scalar($_POST['line'])
|
|
and preg_match('/\d+/', (string)$_POST['line'], $line_matches)) {
|
|
$line = min((int)$line_matches[0], 65535);
|
|
}
|
|
|
|
$levels = array_flip(ZM\Logger::$codes);
|
|
if (!isset($levels[$level])) {
|
|
ZM\Error('Unexpected logger level '.$level); // already sanitized above
|
|
$level = 'ERR';
|
|
}
|
|
ZM\Logger::fetch()->logPrint($levels[$level], $message, $file, $line);
|
|
}
|
|
|
|
function queryRequest() {
|
|
// Offset specifies the starting row to return, used for pagination
|
|
$offset = 0;
|
|
if (isset($_REQUEST['offset'])) {
|
|
if ((!is_int($_REQUEST['offset']) and !ctype_digit($_REQUEST['offset']))) {
|
|
ZM\Error('Invalid value for offset: ' . $_REQUEST['offset']);
|
|
} else {
|
|
$offset = (int)$_REQUEST['offset'];
|
|
}
|
|
}
|
|
|
|
// Limit specifies the number of rows to return
|
|
$limit = 100;
|
|
if (isset($_REQUEST['limit'])) {
|
|
if ((!is_int($_REQUEST['limit']) and !ctype_digit($_REQUEST['limit']))) {
|
|
ZM\Error('Invalid value for limit: ' . $_REQUEST['limit']);
|
|
} else {
|
|
$limit = (int)$_REQUEST['limit'];
|
|
}
|
|
}
|
|
// The table we want our data from
|
|
$table = 'Logs';
|
|
|
|
$nameMainQuery = 't1'; # To optimize queries using a subquery
|
|
$nameSubQuery = 't2';
|
|
|
|
// The names of the dB columns in the log table we are interested in
|
|
$columns = array('Id', 'TimeKey', 'Component', 'ServerId', 'Pid', 'Code', 'Message', 'File', 'Line');
|
|
$columnsContext = $columns;
|
|
// The names of columns shown in the log view that are NOT dB columns in the database
|
|
$col_alt = array('DateTime', 'Server');
|
|
|
|
$sort = 'TimeKey';
|
|
if (isset($_REQUEST['sort'])) {
|
|
$sort = $_REQUEST['sort'];
|
|
if ($sort == 'DateTime') $sort = 'TimeKey';
|
|
if ($sort == 'Server') $sort = 'ServerId';
|
|
}
|
|
if (!in_array($sort, array_merge($columns, $col_alt))) {
|
|
ZM\Error('Invalid sort field: ' . $sort);
|
|
return;
|
|
}
|
|
|
|
// Order specifies the sort direction, either asc or desc
|
|
$order = (isset($_REQUEST['order']) and (strtolower($_REQUEST['order']) == 'asc')) ? 'ASC' : 'DESC';
|
|
|
|
if ($nameMainQuery !== '' && $nameSubQuery !== '') {
|
|
array_walk($columnsContext, function(&$value, $key, $nameMainQuery) {
|
|
$value = $nameMainQuery . '.' . $value;
|
|
}, $nameMainQuery);
|
|
}
|
|
|
|
$col_str = implode(', ', $columns);
|
|
$col_str_context = implode(', ', $columnsContext);
|
|
$data = array();
|
|
$query = array();
|
|
$query['values'] = array();
|
|
$likes = array();
|
|
$where = '';
|
|
// There are two search bars in the log view, normal and advanced
|
|
// Making an exuctive decision to ignore the normal search, when advanced search is in use
|
|
// Alternatively we could try to do both
|
|
//
|
|
// Advanced search contains an array of "column name" => "search text" pairs
|
|
// Bootstrap table sends json_ecoded array, which we must decode
|
|
$advsearch = isset($_REQUEST['filter']) ? json_decode($_REQUEST['filter'], JSON_OBJECT_AS_ARRAY) : array();
|
|
// Search contains a user entered string to search on
|
|
$search = isset($_REQUEST['search']) ? $_REQUEST['search'] : '';
|
|
if (count($advsearch)) {
|
|
foreach ($advsearch as $col=>$text) {
|
|
if (!in_array($col, array_merge($columns, $col_alt))) {
|
|
ZM\Error("'$col' is not a searchable column name");
|
|
continue;
|
|
}
|
|
// Don't use wildcards on advanced search
|
|
//$text = '%' .$text. '%';
|
|
array_push($likes, $col.' LIKE ?');
|
|
array_push($query['values'], $text);
|
|
}
|
|
$where = '(' .implode(' OR ', $likes). ')';
|
|
|
|
} else if ($search != '') {
|
|
$search = '%' .$search. '%';
|
|
foreach ( $columns as $col ) {
|
|
array_push($likes, $col.' LIKE ?');
|
|
array_push($query['values'], $search);
|
|
}
|
|
$where = '(' .implode(' OR ', $likes). ')';
|
|
}
|
|
|
|
// Component is a multi-select: accept a scalar (legacy) or an array of
|
|
// component names and match any of them.
|
|
$requestComponents = array();
|
|
if (isset($_REQUEST['Component'])) {
|
|
foreach ((array)$_REQUEST['Component'] as $component) {
|
|
if (is_scalar($component) && (string)$component !== '') $requestComponents[] = (string)$component;
|
|
}
|
|
}
|
|
if (count($requestComponents)) {
|
|
if ($where) $where .= ' AND ';
|
|
$placeholders = implode(', ', array_fill(0, count($requestComponents), '?'));
|
|
$where .= 'Component IN (' . $placeholders . ')';
|
|
foreach ($requestComponents as $component) $query['values'][] = $component;
|
|
}
|
|
|
|
if (!empty($_REQUEST['ServerId'])) {
|
|
if ($where) $where .= ' AND ';
|
|
$where .= 'ServerId = ?';
|
|
$query['values'][] = $_REQUEST['ServerId'];
|
|
}
|
|
/* We have an indexed 'Level', not 'Code'.
|
|
if (!empty($_REQUEST['level'])) {
|
|
if ($where) $where .= ' AND ';
|
|
$where .= 'Code = ?';
|
|
$query['values'][] = $_REQUEST['level'];
|
|
}
|
|
*/
|
|
// Level is a multi-select: accept a scalar (legacy) or an array of level codes
|
|
// ('DBG', 'INF', ...). Keep only recognized codes and match any of them.
|
|
$level_codes = array_flip(ZM\Logger::$codes);
|
|
$requestLevels = array();
|
|
if (isset($_REQUEST['level'])) {
|
|
foreach ((array)$_REQUEST['level'] as $level) {
|
|
if (is_scalar($level) && isset($level_codes[(string)$level])) {
|
|
$requestLevels[(string)$level] = $level_codes[(string)$level];
|
|
}
|
|
}
|
|
}
|
|
if (count($requestLevels)) {
|
|
if ($where) $where .= ' AND ';
|
|
$placeholders = implode(', ', array_fill(0, count($requestLevels), '?'));
|
|
$where .= ' Level IN (' . $placeholders . ')';
|
|
foreach ($requestLevels as $code) $query['values'][] = $code;
|
|
}
|
|
|
|
if (!empty($_REQUEST['StartDateTime'])) {
|
|
$start_time = strtotime($_REQUEST['StartDateTime']);
|
|
if ($start_time) {
|
|
if ($where) $where .= ' AND ';
|
|
$where .= 'TimeKey >= ?';
|
|
$query['values'][] = $start_time;
|
|
} else {
|
|
ZM\Warning("Unable to parse StartDateTime ".$_REQUEST['StartDateTime']. " into a timestamp");
|
|
}
|
|
}
|
|
if (!empty($_REQUEST['EndDateTime'])) {
|
|
$end_time = strtotime($_REQUEST['EndDateTime']);
|
|
if ($end_time) {
|
|
if ($where) $where .= ' AND ';
|
|
$where .= 'TimeKey <= ?';
|
|
$query['values'][] = $end_time;
|
|
} else {
|
|
ZM\Warning("Unable to parse EndDateTime ".$_REQUEST['EndDateTime']. " into a timestamp");
|
|
}
|
|
}
|
|
|
|
# An embedded panel queries a component of its own choosing, so remembering
|
|
# what it asked for would overwrite the Log view's selection.
|
|
if (empty($_REQUEST['embedded'])) {
|
|
zm_session_start();
|
|
$_SESSION['zmLogComponent'] = $requestComponents;
|
|
$_SESSION['zmLogFilterLevel'] = array_keys($requestLevels);
|
|
session_write_close();
|
|
}
|
|
|
|
if ($where) $where = ' WHERE '.$where;
|
|
|
|
$data['totalNotFiltered'] = dbFetchOne('SELECT count(*) AS Total FROM `' .$table.'`', 'Total');
|
|
if ($where) {
|
|
$data['total'] = dbFetchOne('SELECT count(*) AS Total FROM `' .$table.'` '.$where, 'Total', $query['values']);
|
|
} else {
|
|
$data['total'] = $data['totalNotFiltered'];
|
|
}
|
|
|
|
if ($nameMainQuery !== '' && $nameSubQuery !== '') { # Optimized query
|
|
$query['sql'] = '
|
|
SELECT ' .$col_str_context. '
|
|
FROM `' .$table. '` ' .$nameMainQuery. '
|
|
JOIN (
|
|
SELECT Id
|
|
FROM `'.$table.'` '.$where. '
|
|
ORDER BY ' .$sort. ' ' .$order. '
|
|
LIMIT ?, ?
|
|
) AS ' .$nameSubQuery. '
|
|
ON ' .$nameMainQuery. '.Id=' .$nameSubQuery. '.Id
|
|
ORDER BY ' .$nameMainQuery. '.' .$sort. ' ' .$order;
|
|
} else {
|
|
$query['sql'] = 'SELECT ' .$col_str. ' FROM `' .$table. '` ' .$where. ' ORDER BY ' .$sort. ' ' .$order. ' LIMIT ?, ?';
|
|
}
|
|
|
|
array_push($query['values'], $offset, $limit);
|
|
|
|
$rows = array();
|
|
$results = dbFetchAll($query['sql'], NULL, $query['values']);
|
|
|
|
global $dateTimeFormatter;
|
|
foreach ($results as $row) {
|
|
$row['DateTime'] = empty($row['TimeKey']) ? '' : $dateTimeFormatter->format(intval($row['TimeKey']));
|
|
$Server = $row['ServerId'] ? ZM\Server::find_one(array('Id'=>$row['ServerId'])) : null;
|
|
|
|
$row['Server'] = $Server ? $Server->Name() : '';
|
|
$row['Message'] = htmlspecialchars($row['Message'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
|
// Strip out all characters that are not ASCII 32-126 (yes, 126)
|
|
$row['File'] = preg_replace('/[^\x20-\x7E]/', '', strip_tags($row['File']));
|
|
$rows[] = $row;
|
|
}
|
|
$data['rows'] = $rows;
|
|
$data['logstate'] = logState();
|
|
$data['updated'] = $dateTimeFormatter->format(time());
|
|
|
|
return $data;
|
|
}
|