Files
Isaac ConnorandClaude Opus 5 78f389cdea feat: add ZM_ALLOWED_COMMANDS to restrict what filters and tasks may run refs #4957
Two places in ZoneMinder run a command the operator typed: a filter with
"Execute command", and the scheduled tasks added for #4957. Both run it as the
web user through a shell, which is close to shell access on the server. Until
now the only control over that was who may edit, and the answer was anyone
with System rights.

ZM_ALLOWED_COMMANDS names the programs that may be run, comma separated.
Empty is the default and places no restriction, so an upgrade changes nothing
and the filters people already have keep working; setting it narrows both
features at once.

Only the program is matched, because an exact whole-command match could never
work for filters: zmfilter.pl appends the event path to whatever the filter
stored, so no two runs have the same command line. Arguments are therefore
free, which means the program being on the list has to be the whole of the
guarantee -- so while a list is in force a command may not contain the
characters that start another program: semicolon, pipe, ampersand, backtick
and dollar-paren. Without that, "/usr/local/bin/backup.sh; anything" would
pass on the strength of its first word. Redirection is deliberately allowed;
it starts no program, and refusing it would break the ordinary
"... >> /var/log/mine.log".

Refused in three places, because each answers a different question. The
filter and scheduled task editors refuse on save, so the operator is told at
the time rather than by a log line days later. zmfilter.pl refuses at the
moment it runs, because a row can reach the database without passing through
the web ui and because the setting may have been narrowed since. And
cronRebuild leaves a newly disallowed task out of the crontab, so tightening
the setting takes jobs away rather than waiting for somebody to edit each one.

The rule exists twice, in web/includes/Command.php and ZoneMinder::General,
since one is PHP and the other Perl. Rather than hope they stay in step,
tests/php/test_allowed_commands.php puts every case to both and fails if they
disagree. That earned itself immediately: both split the command on
whitespace before handling quotes, so a program path containing a space was
cut in half and compared as the wrong string. The cross-check found it in
both at once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-28 18:01:25 -05:00

74 lines
2.6 KiB
PHP

<?php
namespace ZM;
require_once('database.php');
require_once('Object.php');
require_once('CronTab.php');
require_once('Command.php');
// A command ZoneMinder puts in the web user's crontab for cron to run.
//
// The counterpart to a scheduled MonitorAction: that one runs a device
// command ZoneMinder already knows how to build, this one runs whatever the
// operator says. Both end up as lines in the same marked block, written by
// cronRebuild().
class CronJob extends ZM_Object {
protected static $table = 'CronJobs';
protected $defaults = array(
'Id' => null,
'Name' => '',
'Enabled' => 1,
'Schedule' => '',
'Command' => '',
'ServerId' => null,
);
public static function find($parameters = array(), $options = array()) {
return ZM_Object::_find(self::class, $parameters, $options);
}
public static function find_one($parameters = array(), $options = array()) {
return ZM_Object::_find_one(self::class, $parameters, $options);
}
public function Server() {
if (!$this->{'ServerId'}) return null;
require_once('Server.php');
return Server::find_one(array('Id'=>$this->{'ServerId'}));
}
// Whether this job belongs in the crontab of the server running this code.
// A job with no server named belongs on every server, which is what a single
// server install wants and gets without having to say so.
public function belongsOnThisServer() {
if (!$this->{'ServerId'}) return true;
if (!defined('ZM_SERVER_ID') or !ZM_SERVER_ID) return true;
return ($this->{'ServerId'} == ZM_SERVER_ID);
}
// The crontab line, or null when this job is not one to write.
//
// Validated again here rather than trusted from the row: the database is
// not the boundary, the crontab is, and a row could have been written by
// something that did not check.
public function cronLine() {
if (!$this->{'Enabled'}) return null;
if (!cronScheduleIsValid($this->{'Schedule'})) return null;
if (!cronCommandIsValid($this->{'Command'})) return null;
// Asked again on every rebuild, so narrowing ZM_ALLOWED_COMMANDS drops
// the jobs it no longer covers out of the crontab rather than leaving
// them running until somebody edits each one.
if (!commandAllowedByConfig($this->{'Command'})) {
Warning('Scheduled task '.$this->{'Id'}.' is not in ZM_ALLOWED_COMMANDS, leaving it out of the crontab');
return null;
}
$line = trim($this->{'Schedule'}).' '.trim($this->{'Command'});
// Named so somebody reading the crontab can find the row it came from.
$line .= ' # ZoneMinder job '.intval($this->{'Id'});
return $line;
}
}
?>