Files
zoneminder/tests/php/test_api_event_view_acl.php
T
Nic Boet 7a64e50618 fix(api): refuse events/view for an event on a monitor the user may not view
EventsController::view() looks the event up with the per-monitor
condition and then asks ZM\Event::canView(). For an event on a denied
monitor the restricted find() is empty, the empty ZM\Event has no
monitor, and canView() falls back to the user's global Monitors
permission, so it passes. The action then answered 200 with a stub
built from the id alone: the hidden event's Next/Prev ids, its max
score and alarm frame ids, fileSize and a FileSystemPath.

The event is known to exist at that point, so an empty result means
the monitor condition removed it: answer 401 as edit, delete and
archive do.

Test: tests/php/test_api_event_view_acl.php (2 of 6 checks fail
without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit cf62c055763afa8fbaaa5925abe1659fda1544ec)
2026-10-31 19:08:08 -04:00

145 lines
5.0 KiB
PHP

<?php
// Tests EventsController::view() against the per-monitor ACL:
// an event on a monitor the user may not view must be refused (401), not
// answered with a stub carrying the hidden event's neighbours and frame ids.
//
// The restricted find() comes back empty for such an event. An empty ZM\Event
// has no monitor, and ZM\Event::canView() then falls back to the user's global
// Monitors permission, so canView() alone does not catch it. The stub Event
// below behaves the same way.
//
// Cake, the models and ZM\Event are stubbed, so this runs from a source
// checkout: php tests/php/test_api_event_view_acl.php
namespace ZM {
class Event {
public $row;
public function __construct($row = null) { $this->row = is_array($row) ? $row : array(); }
// Like the real one for an empty event: no monitor, so the global
// Monitors permission decides, which the test user has.
public function canView() {
if (!isset($this->row['MonitorId'])) return true;
return in_array($this->row['MonitorId'], $GLOBALS['viewable']);
}
public function Path() {
return '/events/'.(isset($this->row['MonitorId']) ? $this->row['MonitorId'] : '').'/'.(isset($this->row['Id']) ? $this->row['Id'] : '');
}
}
function Debug($s) {}
}
namespace {
class App {
public static function uses($class, $package) {}
}
function __($s) { return $s; }
class NotFoundException extends Exception {}
class UnauthorizedException extends Exception {}
// Events 1 (monitor 1), 2 (monitor 2), 3 (monitor 1). Monitor 2 is denied.
$GLOBALS['events'] = array(
1 => array('Id' => 1, 'MonitorId' => 1),
2 => array('Id' => 2, 'MonitorId' => 2),
3 => array('Id' => 3, 'MonitorId' => 1),
);
$GLOBALS['viewable'] = array(1);
class StubEventModel {
public $recursive;
public $primaryKey = 'Id';
public $id;
public function exists($id = null) { return isset($GLOBALS['events'][$id]); }
public function unbindModel($m) {}
public function fileExists($e) { return 0; }
public function fileSize($e) { return 0; }
public function find($type, $options = array()) {
if ($type == 'first') {
$id = null;
$allowed = null;
foreach ($options['conditions'] as $c) {
if (isset($c['Event.Id'])) $id = $c['Event.Id'];
if (isset($c['Event.MonitorId'])) $allowed = $c['Event.MonitorId'];
}
if (!isset($GLOBALS['events'][$id])) return array();
$row = $GLOBALS['events'][$id];
if ($allowed !== null and !in_array($row['MonitorId'], $allowed)) return array();
return array('Event' => $row);
}
if ($type == 'neighbors') {
return array(
'prev' => isset($GLOBALS['events'][$this->id - 1]) ? array('Event' => array('Id' => $this->id - 1)) : null,
'next' => isset($GLOBALS['events'][$this->id + 1]) ? array('Event' => array('Id' => $this->id + 1)) : null,
);
}
return array();
}
}
class StubFrameModel {
public function findByEventid($id) { return array('Frame' => array('FrameId' => 100 + $id)); }
public function findByEventidAndType($id, $type) { return array('Frame' => array('FrameId' => 100 + $id)); }
}
class StubRequest {
public function query($k) { return null; }
}
class AppController {
public $Event;
public $Frame;
public $request;
public $viewVars = array();
public function __construct() {
$this->Event = new StubEventModel();
$this->request = new StubRequest();
}
public function loadModel($m) { if ($m == 'Frame') $this->Frame = new StubFrameModel(); }
public function set($v) { $this->viewVars = $v; }
// As AppController: restrict to the viewable ids when some are not viewable.
protected function viewableMonitorCondition($field) {
return array($field => $GLOBALS['viewable']);
}
}
require_once __DIR__.'/../../web/api/app/Controller/EventsController.php';
$failures = 0;
$passes = 0;
function check($name, $got, $expected) {
global $failures, $passes;
if ($got === $expected) {
$passes++;
echo "ok - $name\n";
} else {
$failures++;
echo "FAIL - $name (got ".var_export($got, true).', expected '.var_export($expected, true).")\n";
}
}
function view($id) {
$c = new EventsController();
try {
$c->view($id);
} catch (Exception $e) {
return array(get_class($e), null);
}
return array('ok', $c->viewVars['event']['Event']);
}
list($r, $e) = view(1);
check('viewable event is returned', $r, 'ok');
check('viewable event keeps its own Id', $e['Id'], 1);
check('viewable event has its neighbours', $e['Next'], 2);
list($r, $e) = view(2);
check('event on a denied monitor is refused with 401', $r, 'UnauthorizedException');
check('no stub body for an event on a denied monitor', $e, null);
list($r, $e) = view(99);
check('missing event is still a 404', $r, 'NotFoundException');
echo "\n$passes passed, $failures failed\n";
exit($failures ? 1 : 0);
}