When ZM_AUTH_HASH_SECRET is unusable zmu refuses tokens, so
zmuCredentials() falls back to what zmu got before. With
ZM_AUTH_RELAY=plain the API's alarm action used to pass the password
from the request (pass= or password=); the fallback read only the
session, which an API call without a stateful login doesn't have, so
zmu refused the call (rc 255). zmuCredentials() takes the caller's
password first again. With a usable secret it is never used.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 5ca19c2703ea9211fe040991305ce9b0a873faff)
getZmuCommand() and the API's alarm action put the user's credentials on
zmu's command line, where every local user can read them (ps,
/proc/<pid>/cmdline): an auth hash with the default hashed relay, the
plaintext password with ZM_AUTH_RELAY=plain. The API also wrote the
whole command to the debug log. With the default ZM_AUTH_HASH_IPS=0 that
hash is accepted as an auth= login from any address for ZM_AUTH_HASH_TTL
hours.
zmuCredentials() builds them in one place: with a usable
ZM_AUTH_HASH_SECRET, an access token for the user that expires after a
minute (zmu -T, accepted with every relay). Without the secret zmu
refuses tokens, so it gets what it did before. The API logs the command
without the credentials.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 32bc2b3fee87b752590ba6f210403afd9222d9ff)
The monitors/alarm command:on cause was escaped with escapeshellarg(),
which drops bytes that are invalid in the server's locale and so mangles
a UTF-8 cause (for example an accented word) under a C/POSIX locale,
even though alarmCause() had kept those characters. Wrap the cause in
single quotes with POSIX quote escaping instead, which preserves every
byte and stays safe independently of what alarmCause() lets through.
Read the cause by comparing the query parameter to null before falling
back to POST data, so a cause of literally "0" is passed through rather
than discarded as a falsy value.
refs #5348
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
monitors/alarm/command:on now takes an optional cause (query or POST data)
and passes it to zmu --cause, so the event records it in place of
"Forced Web". Without a cause nothing changes.
The cause is stored as the event's Cause and in its Notes, so alarmCause()
keeps it to plain text: letters, digits, spaces and plain punctuation, cut
to the 31 bytes zmu stores on a character boundary, falling back to the
default when nothing is left. The value is quoted with escapeshellarg()
for the zmu command line.
Test: tests/php/test_monitor_alarm_cause.php
fixes#5348
MonitorsController::edit() restarted zmc through the CakePHP model but
left the monitor's control server running. zmcontrol.pl reads
ControlAddress, ControlDevice and the Control when it starts, so after
an API edit PTZ kept using the old values: a camera password changed
through the API kept failing PTZ with 401 until zmcontrol.pl was
restarted by hand.
Restart the daemons the way the console's save does
(includes/actions/monitor.php), through ZM\Monitor: stop zmc and, for a
controllable monitor, zmcontrol.pl; start them again from the saved row
unless the monitor does not capture, is a WebSite or is deleted.
ZM\Monitor also sends the commands to the monitor's own server in a
multi-server setup, where the model's daemonControl() logged an error and
did nothing.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
zmc runs a monitor's EventStartCommand and EventEndCommand at the start
and end of each event. Both the classic save and bulk edit actions and
the API monitor add and edit authorized only on the monitor, so a user
with edit on one monitor and no System permission could set a command
for zmc to run as the ZoneMinder service user.
Changing either field now needs System=Edit, as Filter::canEdit()
already requires for AutoExecuteCmd. Monitor::commandFieldChangesNotAllowed()
reports the command fields a change would alter; submitting the current
value is not a change, so other users can still save the rest of the
monitor. The monitor form shows the fields read-only to them. Also
close the EventEndCommand list item.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DELETE /monitors/:id hard-deleted the Monitors row through CakePHP.
Zones went with it, but the monitor's Events, Monitor_Status and
Event_Summaries rows were left pointing at a monitor that no longer
exists. The console deletes with ZM\Monitor::delete(): it stops zmc
and zmcontrol and marks the monitor Deleted, keeping its events
("Events will age out") and letting the monitor be undeleted.
Use the same call in the API.
refs #5175
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The API alarm action and ajax/alarm.php checked no per-monitor
permission and relied on zmu, which only requires that the user can see
the monitor. A user with view on a monitor could force, cancel or disable
its alarms. Changing alarm state now needs Monitor::canEdit(), and the
API status query needs canView().
Monitors API edit and add also pin the record id, so an Id in the body
cannot redirect the save to another monitor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
The monitors index unconditionally LEFT JOINed Groups_Monitors and collapsed
the duplicate rows (monitors in multiple groups) with GROUP BY `Monitor`.`Id`.
That GROUP BY fails under ONLY_FULL_GROUP_BY on engines without functional
dependency detection (MariaDB), raising 1055 'Monitor.Name isn't in GROUP BY',
so /api/monitors.json returned a 500 while /api/monitors/<id>.json worked.
Only join Groups_Monitors when the request filters by group (matching the
existing EventsController pattern and the original commented-out intent), drop
the GROUP BY, and dedupe by monitor Id in the existing result loop to cover
multi-value GroupId filters. Portable across MySQL and MariaDB.
refs #3633
FilterTerm.php:
- Replace eval() with safe compare() method for SystemLoad, DiskPercent,
and DiskBlocks filter conditions (RCE via crafted op/val)
- Validate operator against allowlist in constructor
- Sanitize collate field to alphanumeric/underscore only (SQLi)
onvifprobe.php:
- Use escapeshellarg() on interface, device_ep, soapversion, username,
and password arguments passed to execONVIF() (command injection)
Event.php:
- Use escapeshellarg() on all arguments to zmvideo.pl instead of
escapeshellcmd() on the whole command (command injection via format)
- Anchor scale regex with ^ and $ to prevent partial matches
image.php:
- Restrict proxy URL scheme to http/https only (SSRF via file:// etc)
filterdebug.php:
- Use already-sanitized $fid instead of raw $_REQUEST['fid'] (XSS)
MonitorsController.php:
- Use escapeshellarg() on token, username, password, and monitor id
in zmu shell command instead of escapeshellcmd() on whole command
HostController.php:
- Use escapeshellarg() on path in du command (command injection via mid)
- Remove space from daemon name allowlist (argument injection)
EventsController.php:
- Remove single quotes from interval expression regex (SQLi)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The Device field from the Monitors table was interpolated directly into
shell commands (qx(), backticks, exec()) without sanitization, allowing
authenticated users with monitor-edit permissions to execute arbitrary
commands as www-data via the Device Path field.
Defense in depth:
- Input validation: reject Device values not matching /^\/dev\/[\w\/.\-]+$/
at save time in both web UI and REST API
- Output sanitization: use escapeshellarg() in PHP and quote validated
values in Perl at every shell execution point
Affected locations:
- scripts/ZoneMinder/lib/ZoneMinder/Monitor.pm (control, zmcControl)
- scripts/zmpkg.pl.in (system startup)
- web/includes/Monitor.php (zmcControl)
- web/includes/functions.php (zmcStatus, zmcCheck, validDevicePath)
- web/includes/actions/monitor.php (save action)
- web/api/app/Model/Monitor.php (daemonControl, validation rules)
- web/api/app/Controller/MonitorsController.php (daemonStatus)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Populate a global from the session on every request. Use the object instead of using allowedMonitors in session.
* fix when gets loaded.
* use for auth, and add Monitor Edit checks to Zone add/delete/edit
* add back the ZM_OPT_USE_AUTH test for being logged in in AppController
* Update permissions code to use
* change quotes
* Update permission code to use
* Use instal of session for systemPermission
* deprecate montiorPermision in session
* use instead of session streamPermission
* move login code back into AppController. Has to be done for every request
* deprecate eventPermission, controlPermission and systemPermission in session.
* handle auth params in query string as well as post
* exit on HUP to free up memory.
* add missing global user
* system should be System