mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-10-29 09:48:32 -04:00
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>
111 lines
4.4 KiB
PHP
111 lines
4.4 KiB
PHP
<?php
|
|
//
|
|
// ZoneMinder command allow-list
|
|
// Copyright (C) 2026 ZoneMinder LLC
|
|
//
|
|
// This program is free software; you can redistribute it and/or
|
|
// modify it under the terms of the GNU General Public License
|
|
// as published by the Free Software Foundation; either version 2
|
|
// of the License, or (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program; if not, write to the Free Software
|
|
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|
//
|
|
// Two places in ZoneMinder run a command the operator typed: a filter with
|
|
// "Execute command", and a scheduled task. Both run it as the web user through
|
|
// a shell, which is close to shell access on the server.
|
|
//
|
|
// ZM_ALLOWED_COMMANDS narrows that to a list the administrator chose. Empty,
|
|
// which is the default and what every existing install has, places no
|
|
// restriction and keeps the behaviour ZoneMinder has always had.
|
|
//
|
|
// The same rule is implemented in Perl, in ZoneMinder::General, because
|
|
// zmfilter.pl has to apply it at the moment it runs a command and not merely
|
|
// when the web ui saved one. tests/php/test_allowed_commands.php checks the
|
|
// two against each other.
|
|
//
|
|
|
|
// The characters that let one command start another. While a list is in
|
|
// force these are refused outright: a program being on the list says nothing
|
|
// about what else runs alongside it if the command can chain.
|
|
//
|
|
// Redirection is not here. It does not start a program, and refusing it would
|
|
// stop the ordinary "... >> /var/log/mine.log" that people reasonably write.
|
|
define('ZM_COMMAND_CHAINING', array(';', '|', '&', '`', '$('));
|
|
|
|
// The program a command invokes: its first word, with any surrounding quotes
|
|
// removed. Null when there is not one.
|
|
//
|
|
// Only the program is matched, so arguments may vary from run to run -- which
|
|
// they must, since zmfilter.pl appends the event path to whatever the filter
|
|
// has stored.
|
|
function commandProgram($command) {
|
|
if (!is_string($command)) return null;
|
|
$command = trim($command);
|
|
if ($command === '') return null;
|
|
|
|
// A quoted path may contain spaces, so the quoted run has to be taken
|
|
// before splitting on whitespace -- splitting first would cut
|
|
// "/usr/local/bin/my script.sh" in half and compare the wrong thing.
|
|
if (($command[0] === '"') or ($command[0] === "'")) {
|
|
$quote = $command[0];
|
|
$end = strpos($command, $quote, 1);
|
|
// An unterminated quote is not a program name we can read.
|
|
if ($end === false) return null;
|
|
$program = substr($command, 1, $end - 1);
|
|
return ($program === '') ? null : $program;
|
|
}
|
|
|
|
$parts = preg_split('/\s+/', $command, 2);
|
|
$program = $parts[0];
|
|
return ($program === '') ? null : $program;
|
|
}
|
|
|
|
// Parse the configured list into the programs it names.
|
|
function commandAllowList($setting) {
|
|
$allowed = array();
|
|
if (!is_string($setting)) return $allowed;
|
|
foreach (explode(',', $setting) as $entry) {
|
|
$entry = trim($entry);
|
|
if ($entry !== '') $allowed[] = $entry;
|
|
}
|
|
return $allowed;
|
|
}
|
|
|
|
// May $command be run, given the configured list?
|
|
//
|
|
// $allowed is the raw setting; an empty one means no restriction. Pure, so
|
|
// the rule can be tested without a database.
|
|
function commandIsAllowed($command, $allowed) {
|
|
$list = commandAllowList($allowed);
|
|
// No list configured: unrestricted, as ZoneMinder has always been.
|
|
if (!count($list)) return true;
|
|
|
|
if (!is_string($command)) return false;
|
|
foreach (ZM_COMMAND_CHAINING as $chain) {
|
|
if (strpos($command, $chain) !== false) return false;
|
|
}
|
|
|
|
$program = commandProgram($command);
|
|
if ($program === null) return false;
|
|
return in_array($program, $list, true);
|
|
}
|
|
|
|
// The same question, asked of the running configuration.
|
|
function commandAllowedByConfig($command) {
|
|
return commandIsAllowed($command, defined('ZM_ALLOWED_COMMANDS') ? ZM_ALLOWED_COMMANDS : '');
|
|
}
|
|
|
|
// What to tell someone whose command was refused, naming what is allowed so
|
|
// the answer is actionable rather than just "no".
|
|
function commandRefusedMessage() {
|
|
$list = commandAllowList(defined('ZM_ALLOWED_COMMANDS') ? ZM_ALLOWED_COMMANDS : '');
|
|
return translate('CommandNotAllowed').' '.implode(', ', $list);
|
|
}
|