Files
zoneminder/web/api/app/Controller/AppController.php
T
Nic Boet 48a11c1bb7 fix: snapshot and tag edits keep the event links the editor can't view
An edit replaces all of a snapshot's or tag's event links, but only the
new list was checked, so {"Event": []} from a user denied a monitor
unlinked that monitor's events, and any list without them dropped them.
The API's view of a snapshot or tag leaves those events out, so a client
that reads the list, changes it and saves it dropped them too.

The new list still needs view on every event it links. The links to
events the user may not view are now added back to the list before the
save (eventLinksToSave()), so the user can change the links they see and
the others stay. A link to an event that no longer exists is dropped.

Test: tests/php/test_api_associated_ids.php
(cherry picked from commit 6b1d0eaf8d)
2026-10-31 18:45:52 -04:00

321 lines
14 KiB
PHP

<?php
/**
* Application level Controller
*
* This file is application-wide controller file. You can put all
* application-wide controller-related methods here.
*
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @package app.Controller
* @since CakePHP(tm) v 0.2.9
* @license https://www.opensource.org/licenses/mit-license.php MIT License
*/
App::uses('Controller', 'Controller');
App::uses('CrudControllerTrait', 'Crud.Lib');
/**
* Application Controller
*
* Add your application-wide methods in the class below, your controllers
* will inherit them.
*
* @package app.Controller
* @link https://book.cakephp.org/2.0/en/controllers.html#the-app-controller
*/
class AppController extends Controller {
use CrudControllerTrait;
public $components = [
'RequestHandler',
'Crud.Crud' => [
# No Crud actions are mapped. CrudControllerTrait would otherwise answer any action a
# controller does not define (view, add, edit, keyvalue, ...) with a generic handler
# that applies none of the controller's permission or per-monitor checks, e.g.
# zones/view/<id> returned any zone. A controller must implement each action itself.
'actions' => [],
'listeners' => ['Api', 'ApiTransformation']
#],
#'DebugKit.Toolbar' => [
# 'bootstrap' => true, 'routes' => true
]
];
// Global beforeFilter function
//Zoneminder sets the username session variable
// to the logged in user. If this variable is set
// then you are logged in
// its pretty simple to extend this to also check
// for role and deny API access in future
// Also checking to do this only if ZM_OPT_USE_AUTH is on
public function beforeFilter() {
if ( ! ZM_OPT_USE_API ) {
throw new UnauthorizedException(__('API Disabled'));
return;
}
# For use throughout the app. If not logged in, this will be null.
global $user;
require_once __DIR__ .'/../../../includes/auth.php';
# This will auto-login if username=&password= are set, or auth=
zm_authenticate_request();
if ( ZM_OPT_USE_AUTH ) {
if ( ZM_OPT_USE_LEGACY_API_AUTH or !strcasecmp($this->params->action, 'login') ) {
# This is here because historically we allowed user=&pass= in the api. web-ui auth uses username=&password=
$username = $this->request->query('user') ? $this->request->query('user') : $this->request->data('user');
$password = $this->request->query('pass') ? $this->request->query('pass') : $this->request->data('pass');
if ( $username and $password ) {
$ret = validateUser($username, $password);
$user = $ret[0];
$retstatus = $ret[1];
if ( !$user ) {
throw new UnauthorizedException(__($retstatus));
return;
}
ZM\Debug("Login successful for user \"$username\"");
migrateHash($username, $password);
}
}
if ( ZM_OPT_USE_LEGACY_API_AUTH ) {
require_once __DIR__ .'/../../../includes/session.php';
$stateful = $this->request->query('stateful') ? $this->request->query('stateful') : $this->request->data('stateful');
if ( $stateful ) {
// zm_session_start() already populates $_SESSION['remoteAddr'] from
// getRemoteAddr() (X-Forwarded-For via a trusted proxy), matching what
// getAuthUser() uses for validation. Don't overwrite it with bare
// REMOTE_ADDR here — that bound the hash to the proxy IP and broke
// validation behind a reverse proxy.
zm_session_start();
if ($user) {
if ((!empty($username) and !empty($password))
or empty($_SESSION['username']) or $_SESSION['username'] !== $user->Username()) {
// The session takes on a user it did not hold: a password login, or a
// stateful request with a token or an auth hash. A new session id, the
// old session dropped, so an id planted in the client beforehand (any
// anonymous request stores one) doesn't become this user's session
// (session fixation). This also stores the session.
zm_session_regenerate_id_login();
} else {
// The session already is this user's: keep it, and store it even though
// this request arrived without a cookie.
zm_session_persist();
}
$_SESSION['username'] = $user->Username();
// No password in the session: zmu gets a token (zmuCredentials()).
generateAuthHash(ZM_AUTH_HASH_IPS);
}
session_write_close();
} else if ( isset($_COOKIE['ZMSESSID']) and !$user ) {
# Have a cookie set, try to load user by session
if ( ! is_session_started() )
zm_session_start();
$user = userFromSession();
session_write_close();
}
}
# NON LEGACY, token based access
$token = $this->request->query('token') ? $this->request->query('token') : $this->request->data('token');
if ( $token ) {
// if you pass a token to login, we should only allow
// refresh tokens to regenerate new access and refresh tokens
if ( !strcasecmp($this->params->action, 'login') ) {
$only_allow_token_type = 'refresh';
} else {
// for any other methods, don't allow refresh tokens
// they are supposed to be infrequently used for security
// purposes
$only_allow_token_type = 'access';
}
$ret = validateToken($token, $only_allow_token_type, true);
$user = $ret[0];
$retstatus = $ret[1];
if ( !$user ) {
throw new UnauthorizedException(__($retstatus));
return;
}
} # end if token
if ( $user and ( $user->APIEnabled() != 1 ) ) {
ZM\Error('API disabled for: '.$user->Username());
throw new UnauthorizedException(__('API disabled for: '.$user->Username()));
$user = null;
}
// We need to reject methods that are not authenticated
// besides login and logout
if ( strcasecmp($this->params->action, 'logout') ) {
if ( !( $user and $user->Username() ) ) {
throw new UnauthorizedException(__('Not Authenticated'));
return;
} else if ( !( $user and $user->Enabled() ) ) {
throw new UnauthorizedException(__('User is not enabled'));
return;
}
} # end if ! login or logout
} # end if ZM_OPT_AUTH
// make sure populated user object has APIs enabled
if (isset($_SERVER['HTTP_ORIGIN'])) {
global $Servers;
if ( sizeof($Servers) < 1 ) {
# Only need CORSHeaders in the event that there are multiple servers in use.
# ICON: Might not be true. multi-port?
if ( ZM_MIN_STREAMING_PORT ) {
ZM\Debug('Setting default Access-Control-Allow-Origin from ' . $_SERVER['HTTP_ORIGIN']);
$this->response->header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
$this->response->header('Access-Control-Allow-Credentials: true');
$this->response->header('Access-Control-Allow-Headers: x-requested-with,x-request');
}
return;
}
foreach ($Servers as $Server) {
if (
preg_match('/^(https?:\/\/)?'.preg_quote($Server->Hostname(),'/').'/i', $_SERVER['HTTP_ORIGIN'])
or
preg_match('/^(https?:\/\/)?'.preg_quote($Server->Name(),'/').'/i', $_SERVER['HTTP_ORIGIN'])
) {
ZM\Debug('Setting Access-Control-Allow-Origin from '.$_SERVER['HTTP_ORIGIN']);
$this->response->header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
$this->response->header('Access-Control-Allow-Credentials: true');
$this->response->header('Access-Control-Allow-Headers: x-requested-with,x-request');
break;
}
}
}
} # end function beforeFilter()
# Model::save() takes the record id from a primary key in the data it is given, so an
# edit authorized for the id in the URL could otherwise write to whichever id the request
# body names. Drop any primary key from the request data and pin the model to $id.
protected function pinRequestId($model, $id) {
$alias = $model->alias;
$key = $model->primaryKey;
if (isset($this->request->data[$alias]) and is_array($this->request->data[$alias])) {
unset($this->request->data[$alias][$key]);
}
unset($this->request->data[$key]);
$model->id = $id;
}
# A find() condition limiting $field to the monitors the user may view: empty when the user
# is not restricted, and matching nothing when the user may view no monitor at all.
protected function viewableMonitorCondition($field) {
global $user;
if (!$user or !$user->unviewableMonitorIds()) return array();
$ids = $user->viewableMonitorIds();
return array($field => count($ids) ? $ids : array(0));
}
# A field from the request data, or null, read the way Model::set() will save it: from
# Model[field] when the request has a non-empty Model entry, otherwise from the bare field.
# Reading Model[field] whenever Model was an array let {"Monitor":[],"EventEndCommand":"x"}
# pass a check that found no value while save() stored x. refs GHSA-fp33-8fx5-4j6c
protected function requestField($alias, $field) {
$data = $this->request->data;
if (!empty($data[$alias])) {
$data = is_array($data[$alias]) ? $data[$alias] : array();
}
return isset($data[$field]) ? $data[$field] : null;
}
# Throw unless the user may view monitor $monitorId.
protected function requireMonitorView($monitorId) {
require_once __DIR__ .'/../../../includes/Monitor.php';
$monitor = new ZM\Monitor($monitorId);
if (!$monitor->Id()) {
throw new NotFoundException(__('Invalid monitor'));
}
if (!$monitor->canView()) {
throw new UnauthorizedException(__('Insufficient Privileges'));
}
}
# Throw unless the user may edit monitor $monitorId.
protected function requireMonitorEdit($monitorId) {
require_once __DIR__ .'/../../../includes/Monitor.php';
$monitor = new ZM\Monitor($monitorId);
if (!$monitor->Id()) {
throw new NotFoundException(__('Invalid monitor'));
}
if (!$monitor->canEdit()) {
throw new UnauthorizedException(__('Insufficient Privileges'));
}
}
# The ids of the $assoc records a save of the request links to $alias through its HABTM
# association, read the way Model::save() reads them, or null when the save leaves the links
# alone. Model::set() skips a non-array $assoc entry; save() takes $assoc[$assoc] when present,
# else $assoc, and replaces all links with it. $alias[EventIds]-style fields are not saved
# (add() copies them to $assoc first). The entry is rewritten to the plain list returned, so a
# row given as a join record, which save() would store as given, can only link the id checked.
protected function requestAssociatedIds($alias, $assoc) {
if (!isset($this->request->data[$assoc])) return null;
$rows = $this->request->data[$assoc];
if (!is_array($rows)) {
unset($this->request->data[$assoc]);
return null;
}
if (isset($rows[$assoc])) $rows = $rows[$assoc];
$key = $this->{$alias}->hasAndBelongsToMany[$assoc]['associationForeignKey'];
$ids = array();
foreach ((array)$rows as $row) {
if (is_array($row)) $row = isset($row[$key]) ? $row[$key] : null;
if (is_numeric($row)) $ids[] = intval($row);
}
$this->request->data[$assoc] = array($assoc => $ids);
return $ids;
}
# The event list to save for record $id of $table, from the $eventIds an edit asks for: saving
# replaces all of the record's links, so its links to events the user may not view, which the
# API does not show them, are kept. Throw unless the user may view every event in $eventIds.
# Links to events that no longer exist are dropped.
protected function eventLinksToSave($table, $key, $id, $eventIds) {
$this->requireEventsView($eventIds);
$oldIds = dbFetchAll('SELECT `EventId` FROM `'.$table.'` WHERE `'.$key.'`=?', 'EventId', array($id));
foreach (array_diff($oldIds, $eventIds) as $eventId) {
$event = new ZM\Event($eventId);
if ($event->Id() and !$event->canView()) $eventIds[] = intval($eventId);
}
return $eventIds;
}
# Throw unless the user may view every event in $eventIds, which includes its monitor.
protected function requireEventsView($eventIds) {
require_once __DIR__ .'/../../../includes/Event.php';
foreach (array_unique($eventIds) as $eventId) {
$event = new ZM\Event($eventId);
if (!$event->Id()) {
throw new NotFoundException(__('Invalid event'));
}
if (!$event->canView()) {
throw new UnauthorizedException(__('Insufficient Privileges'));
}
}
}
# Drop the events on monitors the user may not view from a row's contained Event list.
protected function filterContainedEvents($row, $key = 'Event') {
$condition = $this->viewableMonitorCondition('MonitorId');
if (!count($condition) or !isset($row[$key])) return $row;
$allowed = $condition['MonitorId'];
$row[$key] = array_values(array_filter($row[$key], function($event) use ($allowed) {
return isset($event['MonitorId']) and in_array($event['MonitorId'], $allowed);
}));
return $row;
}
}