Files
zoneminder/web/api/app/Controller/MonitorsController.php
Isaac Connor 4582091e5e fix: require permission on the monitor daemon API actions
daemonStatus() and daemonControl() checked nothing at all. Any enabled,
API-enabled account could read the state of, and start or stop, the capture and
analysis daemons of any monitor, including monitors it was not permitted to see.
index() and view() have always filtered on ZM\Monitor::canView(), so these two
were the odd ones out.

Confirmed on a live instance with a user whose every permission was None:
monitors.json returned an empty list, while
monitors/daemonControl/1/status.json ran zmdc against that same monitor. On a
running system the equivalent stop call would have halted recording.

Reporting state now needs view on the monitor, and starting or stopping it needs
edit, matching the rest of the controller.

zmdc.pl takes the daemon and the command as separate arguments and they were
interpolated into the command line, escaped only by escapeshellcmd() over the
whole string. That stops metacharacters but not extra arguments, so both are now
checked against the set of names zmdc accepts.

add() and delete() call this internally, having already made their own
permission decision, and a System Edit account need not hold Monitors Edit. They
call an ungated private runner so that re-checking here cannot stop them.
2026-08-15 22:18:47 -04:00

472 lines
15 KiB
PHP

<?php
App::uses('AppController', 'Controller');
/**
* Monitors Controller
*
* @property Monitor $Monitor
* @property PaginatorComponent $Paginator
*/
class MonitorsController extends AppController {
/**
* Components
*
* @var array
*/
public $components = array('Paginator', 'RequestHandler');
public function beforeRender() {
$this->set($this->Monitor->enumValues());
}
public function beforeFilter() {
parent::beforeFilter();
}
/**
* index method
*
* @return void
*/
public function index() {
$this->Monitor->recursive = 0;
if ($this->request->params['named']) {
$this->FilterComponent = $this->Components->load('Filter');
$conditions = $this->FilterComponent->buildFilter($this->request->params['named']);
} else {
$conditions = array();
}
// Only join Groups_Monitors when the request actually filters by group, so
// a bare GroupId named param can resolve against that table. Joining it
// unconditionally multiplied rows for monitors in multiple groups, which we
// used to collapse with GROUP BY `Monitor`.`Id`. That GROUP BY fails under
// ONLY_FULL_GROUP_BY on engines without functional-dependency detection
// (e.g. MariaDB), which rejects the non-grouped SELECT columns. Refs #3633.
$group_filter = false;
foreach ($conditions as $key => $value) {
if (strpos((string)$key, 'GroupId') !== false) { $group_filter = true; break; }
if (is_array($value)) {
foreach ($value as $sub_key => $sub_value) {
if (strpos((string)$sub_key, 'GroupId') !== false) { $group_filter = true; break 2; }
}
}
}
$find_array = array(
'conditions' => &$conditions,
'contain' => array('Group'),
);
if ($group_filter) {
$find_array['joins'] = array(
array(
'table' => 'Groups_Monitors',
'type' => 'left',
'conditions' => array(
'Groups_Monitors.MonitorId = Monitor.Id',
),
),
);
}
$monitors = $this->Monitor->find('all', $find_array);
$allowed_monitors = [];
$seen_monitor_ids = [];
require_once __DIR__ .'/../../../includes/Monitor.php';
foreach ($monitors as $m) {
// A monitor matching multiple GroupId values appears once per match;
// collapse those here rather than with a GROUP BY (see note above).
$monitor_id = $m['Monitor']['Id'];
if (isset($seen_monitor_ids[$monitor_id])) continue;
$seen_monitor_ids[$monitor_id] = true;
$monitor = new ZM\Monitor($m['Monitor']);
if (!$monitor->canView()) continue;
array_push($allowed_monitors, $m);
}
$this->set(array(
'monitors' => $allowed_monitors,
'_serialize' => array('monitors')
));
}
/**
* view method
*
* @throws NotFoundException
* @param string $id
* @return void
*/
public function view($id = null) {
$this->Monitor->recursive = 0;
if (!$this->Monitor->exists($id)) {
throw new NotFoundException(__('Invalid monitor'));
}
require_once __DIR__ .'/../../../includes/Monitor.php';
$options = array('conditions' => array( array('Monitor.'.$this->Monitor->primaryKey => $id)));
$monitor = $this->Monitor->find('first', $options);
$zm_monitor = new ZM\Monitor($monitor['Monitor']);
if (!$zm_monitor->canView()) {
throw new UnauthorizedException(__('Insufficient Privileges'));
return;
}
if ($zm_monitor->JanusEnabled()) {
$monitor['Monitor']['Janus_Pin'] = $zm_monitor->Janus_Pin();
}
$this->set(array(
'monitor' => $monitor,
'_serialize' => array('monitor')
));
}
/**
* add method
*
* @return void
*/
public function add() {
if ( $this->request->is('post') ) {
global $user;
$canAdd = (!$user) || ($user->System() == 'Edit' || $user->Monitors() == 'Create' );
if ( !$canAdd ) {
throw new UnauthorizedException(__('Insufficient privileges'));
return;
}
$this->Monitor->create();
if ($this->Monitor->save($this->request->data) ) {
$this->runDaemonControl($this->Monitor->id, 'start');
//return $this->flash(__('The monitor has been saved.'), array('action' => 'index'));
$message = 'Saved';
} else {
$message = 'Error';
// if there is a validation message, use it
if (!$this->Monitor->validates()) {
$message = $this->Monitor->validationErrors;
}
}
$this->set(array(
'message' => $message,
'_serialize' => array('message')
));
}
}
/**
* edit method
*
* @throws NotFoundException
* @param string $id
* @return void
*/
public function edit($id = null) {
$this->Monitor->id = $id;
if ( !$this->Monitor->exists($id) ) {
throw new NotFoundException(__('Invalid monitor'));
}
$monitor = $this->Monitor->find('first', array(
'conditions' => array('Id' => $id)
))['Monitor'];
require_once __DIR__ .'/../../../includes/Monitor.php';
$zm_monitor = new ZM\Monitor($monitor);
if (!$zm_monitor->canEdit()) {
throw new UnauthorizedException(__('Insufficient Privileges'));
return;
}
$message = '';
if ($this->Monitor->save($this->request->data)) {
$message = 'Saved';
// Stop the monitor. Should happen before saving
$this->Monitor->daemonControl($monitor, 'stop');
$monitor = $this->Monitor->find('first', array(
'conditions' => array('Id' => $id)
))['Monitor'];
if ($monitor['Capturing'] != 'None')
$this->Monitor->daemonControl($monitor, 'start');
} else {
$message = 'Error ' . print_r($this->Monitor->invalidFields(), true);
}
$this->set(array(
'message' => $message,
'_serialize' => array('message')
));
} // end function edit
/**
* delete method
*
* @throws NotFoundException
* @param string $id
* @return void
*/
public function delete($id = null) {
$this->Monitor->id = $id;
if ( !$this->Monitor->exists() ) {
throw new NotFoundException(__('Invalid monitor'));
}
global $user;
$canEdit = (!$user) || ($user->System() == 'Edit');
if ( !$canEdit ) {
throw new UnauthorizedException(__('Insufficient privileges'));
return;
}
$this->request->allowMethod('post', 'delete');
$this->runDaemonControl($this->Monitor->id, 'stop');
if ( $this->Monitor->delete() ) {
return $this->flash(__('The monitor has been deleted.'), array('action' => 'index'));
} else {
return $this->flash(__('The monitor could not be deleted. Please, try again.'), array('action' => 'index'));
}
}
public function sourceTypes() {
$sourceTypes = $this->Monitor->query('describe Monitors Type;');
preg_match('/^enum\((.*)\)$/', $sourceTypes[0]['COLUMNS']['Type'], $matches);
foreach( explode(',', $matches[1]) as $value ) {
$enum[] = trim( $value, "'" );
}
$this->set(array(
'sourceTypes' => $enum,
'_serialize' => array('sourceTypes')
));
}
// arm/disarm alarms
// expected format: http(s):/portal-api-url/monitors/alarm/id:M/command:C.json
// where M=monitorId
// where C=on|off|status|disable
public function alarm() {
$id = $this->request->params['named']['id'];
if ( !$this->Monitor->exists($id) ) {
throw new NotFoundException(__('Invalid monitor'));
}
$cmd = strtolower($this->request->params['named']['command']);
switch ($cmd) {
case 'on':
$q = '-a';
$verbose = '-v';
break;
case 'off':
$q = '-c';
$verbose = '-v';
break;
case 'disable':
$q = '-n';
$verbose = '-v';
break;
case 'status':
$verbose = ''; // zmu has a bug - gives incorrect verbose output in this case
$q = '-s';
break;
default :
throw new BadRequestException(__('Invalid command'));
}
// form auth key based on auth credentials
$auth = '';
if (ZM_OPT_USE_AUTH) {
global $user;
$mToken = $this->request->query('token') ? $this->request->query('token') : $this->request->data('token');;
if ($mToken) {
$auth = ' -T '.escapeshellarg($mToken);
} else if (ZM_AUTH_RELAY == 'hashed') {
$auth = ' -A '.calculateAuthHash(''); # Can't do REMOTE_IP because zmu doesn't normally have access to it.
} else if (ZM_AUTH_RELAY == 'plain') {
# Plain requires the plain text password which must either be in request or stored in session
$password = $this->request->query('pass') ? $this->request->query('pass') : $this->request->data('pass');;
if (!$password)
$password = $this->request->query('password') ? $this->request->query('password') : $this->request->data('password');
if (!$password) {
# during auth the session will have been populated with the plaintext password
$stateful = $this->request->query('stateful') ? $this->request->query('stateful') : $this->request->data('stateful');
if ($stateful) {
$password = $_SESSION['password'];
}
} else if ($_COOKIE['ZMSESSID']) {
$password = $_SESSION['password'];
}
$auth = ' -U '.escapeshellarg($user->Username()).' -P '.escapeshellarg($password);
} else if (ZM_AUTH_RELAY == 'none') {
$auth = ' -U '.escapeshellarg($user->Username());
}
}
$shellcmd = ZM_PATH_BIN.'/zmu'
.($verbose ? " $verbose" : '')
.' -m'.escapeshellarg($id)
." $q"
.$auth;
$status = exec($shellcmd, $output, $rc);
ZM\Debug("Command: $shellcmd output: ".implode(PHP_EOL, $output)." rc: $rc");
if ($rc) {
$this->set(array(
'status'=>'false',
'code' => $rc,
'error'=> implode(PHP_EOL, $output),
'_serialize' => array('status','code','error'),
));
} else if ($cmd == 'status') {
// In 1.36.16 the values got shifted up so that we could index into an array of strings.
// So do a hack to restore the previous behavour
$this->set(array(
'status' => intval($status)-1,
'output' => intval($output[0])-1,
'_serialize' => array('status','output'),
));
} else {
$this->set(array(
'status' => $status,
'output' => implode(PHP_EOL, $output),
'_serialize' => array('status','output'),
));
}
}
// Check if a daemon is running for the monitor id
/**
* Load a monitor the caller is allowed to act on.
*
* daemonStatus() and daemonControl() checked nothing at all, so any enabled,
* API-enabled account could read the state of, and start or stop, the daemons
* of a monitor it is not permitted even to see. index() and view() have always
* filtered on ZM\Monitor::canView(), so the two daemon actions were the odd
* ones out.
*
* @param int $id monitor id
* @param bool $for_edit whether the caller intends to change the monitor's state
* @throws NotFoundException|UnauthorizedException
* @return array the Monitor row
*/
private function monitorForDaemonAction($id, $for_edit) {
if (!$this->Monitor->exists($id)) {
throw new NotFoundException(__('Invalid monitor'));
}
require_once __DIR__ .'/../../../includes/Monitor.php';
$monitor = $this->Monitor->find('first', array(
'conditions' => array('Monitor.'.$this->Monitor->primaryKey => $id)
));
$zm_monitor = new ZM\Monitor($monitor['Monitor']);
# Reporting state needs view; starting or stopping capture is a change to
# the monitor, so it needs edit, the same as any other mutation here.
if ($for_edit ? !$zm_monitor->canEdit() : !$zm_monitor->canView()) {
throw new UnauthorizedException(__('Insufficient Privileges'));
}
return $monitor['Monitor'];
}
/**
* zmdc.pl takes the daemon and command as separate arguments, and they are
* interpolated into the command line, so both are restricted to known values
* rather than merely escaped.
*/
private function assertDaemonAndCommand($daemon, $command) {
$daemons = array('zmc', 'zma', 'zmfilter.pl', 'zmaudit.pl', 'zmtrigger.pl',
'zmwatch.pl', 'zmupdate.pl', 'zmtrack.pl', 'zmcontrol.pl', 'zmstats.pl', 'zmeventnotification.pl');
$commands = array('start', 'stop', 'restart', 'reload', 'status', 'check', 'logrot');
if (($daemon !== null) and !in_array($daemon, $daemons, true)) {
throw new BadRequestException(__('Invalid daemon'));
}
if (!in_array($command, $commands, true)) {
throw new BadRequestException(__('Invalid command'));
}
}
public function daemonStatus() {
$id = $this->request->params['named']['id'];
$daemon = $this->request->params['named']['daemon'];
$this->monitorForDaemonAction($id, false);
if (preg_match('/^[a-z]+$/i', $daemon) !== 1) {
throw new BadRequestException(__('Invalid command'));
}
$monitor = $this->Monitor->find('first', array(
'fields' => array('Id', 'Type', 'Device', 'Capturing'),
'conditions' => array('Id' => $id)
));
// Clean up the returned array
$monitor = Set::extract('/Monitor/.', $monitor);
if ($monitor[0]['Capturing'] == 'None') {
$this->set(array(
'status' => false,
'statustext' => 'Monitor capturing is set to None',
'_serialize' => array('status','statustext'),
));
return;
}
// Pass -d for local, otherwise -m
if ( $monitor[0]['Type'] == 'Local' ) {
$args = '-d '. escapeshellarg($monitor[0]['Device']);
} else {
$args = '-m '. escapeshellarg($monitor[0]['Id']);
}
// Build the command, and execute it
$command = escapeshellcmd(ZM_PATH_BIN."/zmdc.pl status $daemon $args");
$status = exec($command);
ZM\Debug("Command: $command output: $status");
// If 'not' is present, the daemon is not running, so return false
// https://github.com/ZoneMinder/ZoneMinder/issues/799#issuecomment-108996075
// Also sending back the status text so we can check if the monitor is in pending
// state which means there may be an error
$statustext = $status;
$status = (strpos($status, 'not')) ? false : true;
$this->set(array(
'status' => $status,
'statustext' => $statustext,
'_serialize' => array('status','statustext'),
));
}
/**
* Stop or start a monitor's daemons, with no permission check of its own.
*
* Kept separate from the daemonControl() route so that delete(), which has
* already required System Edit, is not additionally required to pass the
* per-monitor edit check that the route applies. A System Edit account need not
* hold Monitors Edit, and re-gating here would have stopped it deleting.
*/
private function runDaemonControl($id, $command, $daemon=null) {
// Need to see if it is local or remote
$monitor = $this->Monitor->find('first', array(
'fields' => array('Id', 'Type', 'Capturing', 'Device', 'ServerId'),
'conditions' => array('Id' => $id)
));
return $this->Monitor->daemonControl($monitor['Monitor'], $command, $daemon);
}
public function daemonControl($id, $command, $daemon=null) {
$this->assertDaemonAndCommand($daemon, $command);
$this->monitorForDaemonAction($id, true);
$status_text = $this->runDaemonControl($id, $command, $daemon);
$this->set(array(
'status' => 'ok',
'statustext' => $status_text,
'_serialize' => array('status','statustext'),
));
} // end function daemonControl
} // end class MonitorsController