Under a threaded MPM (event/worker) Apache blocks signals in its worker
threads and children started by exec() inherit that mask. Where /bin/sh
is bash (Gentoo, Fedora/RHEL, Arch) the mask reaches intel_gpu_top, which
never sees the SIGTERM from "timeout 2", so exec() never returned, the
page never loaded and every visit left another intel_gpu_top running.
dash, Debian's /bin/sh, clears the mask, which is why it doesn't show
there.
- Run the sample with -n 2 where intel_gpu_top supports it, so it exits
by itself after one full period. Support is detected from -h, because
intel-gpu-tools 1.26/1.27 (Ubuntu 22.04, Debian 12) lack -n.
- Without -n, keep "timeout 2" but add -k 3, so a blocked SIGTERM is
followed by an unblockable SIGKILL. The samples printed before the kill
are still parsed; only when there are none does the view explain the
kill instead of showing a raw-output error.
- Give the -h and -L calls a SIGKILL timeout too.
- Report the driver bound to the Intel DRM device and the kernel version
instead of /sys/module/i915/version or modinfo, neither of which gives
a version for i915 or xe, and neither of which exists when the driver
is built in, so Driver always showed Unknown.
- Add dark theme styles for the view's cards, card headers, footer row
and progress bars, which kept Bootstrap's light backgrounds behind the
dark theme's light text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
view_video.php took the end of the range straight from the request and never
checked it against the file:
if (!empty($matches[2])) $end = intval($matches[2]);
$length = $end - $begin + 1;
so on a 1000 byte file:
bytes=0-99999 206, Content-Length 100000, body 1000 bytes
bytes=5000-6000 206, Content-Range naming bytes that do not exist
bytes=500-100 206, Content-Length -399
bytes=1000- 206, Content-Length 0
bytes=-100 200 with the whole file, not the last 100 bytes
A client that is told to expect 100000 bytes and gets 1000 does not see a bad
request, it sees a truncated file, and reports the video as broken. The suffix
form was not recognised at all because the pattern required a digit before the
dash.
This is reached once per fragment by the byte-range HLS manifest VideoStore
writes, every fragment being a Range against the one mp4, so a player that
asks for anything the file cannot supply gets a body that does not match its
own Content-Length rather than an answer it can act on.
Parse the header properly: clamp a range that runs past the end, because a
client may ask for more than is there and is entitled to what is there;
answer 416 with "Content-Range: bytes */size" when the range cannot be
satisfied at all, so the client learns the real length; and read "-N" as the
last N bytes. Length is now derived from the range being served rather than
the one requested, and the send loop counts down by the bytes it actually
read, so Content-Length and the body cannot disagree.
Only the first range of a multi-range request is served, as before. A
multipart/byteranges body is not worth building for this, and falling back to
sending the whole representation is not an option when these are event videos
of hundreds of megabytes; Content-Range names exactly what was sent.
The parsing is its own dependency-free include so it can be tested without a
database, and tests/php/test_http_range.php covers each case above plus a
sweep asserting that every range it ever returns lies inside the file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8c35190b0 moved the Servers lookup into a foreach over $s but kept
define('ZM_SERVER_ID', $Server->Id()), so every request on a node with
ZM_SERVER_NAME in zm.conf died with 'Call to a member function Id() on
null'. Use $s like the ZM_SERVER_HOST branch.
Also make the 'no corresponding entry in Servers table' errors reachable
($thisServer starts as an empty Server object, which is always truthy,
so test its Id) and concatenate the ZM_SERVER_ID error with '.' instead
of '+', which throws a TypeError on PHP 8.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The standalone Control view's controlCmd() still had its 2020
(control, event, xtell, ytell) signature, but ptzControls() binds the
buttons as data-on-mousedown/mouseup handlers that receive the event,
and it read the monitor id with $j('#mid').getAttribute(), which jQuery
objects don't have. Every PTZ button threw before sending anything.
Port the watch view's event-based controlCmd(): the button's value is
the command, mouseup sends moveStop, xge/yge come from data-xtell and
data-ytell. Send requests to the monitor's server (monitorUrl, from
Monitor::UrlToIndex() as on the watch view) instead of this server: on a
multi-server install only the owning server's zmcontrol.pl can drive
the camera, and relaying through Monitor::sendControlCommand() puts the
raw zmcontrol arguments into a daemonControl API path, which fails.
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 State model still declared primaryKey = 'Name' from before
zm_update-1.28.99 gave States an Id primary key. The REST routes only
match numeric ids, so states/<id>.json view/edit/delete looked up a
state *named* <id> and always failed, and names don't match the routes.
Through states/edit/<name>.json, edit never set the record id and
inserted a new nameless state instead of updating; view had no
_serialize and returned 500.
Use the default Id key. view serializes the state; edit sets the id,
accepts POST/PUT only and, like add, answers {message: Saved} or the
validation errors (as Monitors and Zones do) instead of an empty flash.
Names must be non-empty and unique, since zmpkg.pl and
states/change/<name> select states by name. index, change and delete
are unchanged.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
loadFrames() stored each event's frames in FramesById[frame.Id] and linked
PrevFrameId/NextFrameId through frame.Id. With the surrogate Id column
dropped, every frame landed in the same undefined slot, the time lookup
found no frame and montage review showed black. FrameId is unique within
an event, and FramesById is per event, so key and link by FrameId.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
710c6f07e moved the tags-container into the eventStats column, so
everything that hid that column (the narrow-screen auto-hide in
onStatsResize, the Stats button, and the zmEventStats=off cookie on load)
took the tag input with it. On phones the stats are always auto-hidden,
so tags could not be added at all.
Wrap the stats table, location map and alarm frames in eventStatsDetails
and hide only that. A new showEventStats() switches the column to
col-sm-12 when the details are hidden, so the tag bar spans the width
above the video as it did before 710c6f07e, and back to col-sm-3 beside
the col-sm-9 video when they are shown.
The fullscreen double-click handler in skin.js still swapped col-sm-8,
which 710c6f07e missed, leaving both col-sm-9 and col-sm-8 on the video
after leaving fullscreen. It now uses col-sm-9 and restores the layout
through showEventStats() based on whether the details are showing,
instead of forcing the stats back on.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix: give each migrated password its own random bcrypt salt
migratePasswords() stored the Bytes::Random::Secure object rather than
bytes from it, so en_base64() encoded the string
"Bytes::Random::Secure=HASH(0x...)". bcrypt only reads the first 22
characters of the salt, all from the constant class name, so every user
on every install got the same salt. With neither Bytes::Random::Secure
nor Data::Entropy installed the salt was empty and bcrypt() died with
"bad bcrypt settings", aborting zmupdate.pl. Even the Data::Entropy path
reused a single salt for every user in the run.
Read 16 bytes per user from /dev/urandom, as generateAuthHashSecret()
in ZoneMinder::Config already does. If it can't be read, leave that
user's legacy hash in place, which auth.php still verifies, instead of
writing a weak one. This drops the need for Bytes::Random::Secure and
the deprecated Data::Entropy. refs #4333
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore: drop the Data::Entropy dependency from packaging
zmupdate.pl no longer uses Data::Entropy, which upstream has deprecated
(CVE-2025-1860). refs #4333
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: rehash legacy passwords on every password login
Only the login form called migrateHash(), so accounts that sign in with
user=/pass= or through the API kept their mysql or zmupdate.pl
(mysql+bcrypt) hash, including those with the fixed salt. Call it from
those paths too, and have it check the password type itself.
migrateHash() also regenerated the auth hash from the in-memory user,
which still held the old password, so getAuthUser(), which checks
against the new one in the database, rejected it. Update the in-memory
password first, and update the row by Id rather than by the username
as typed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: note passwords still on a zmupdate.pl-migrated hash
Every salt migratePasswords() used before this branch was shared by all
users in a run: from Data::Entropy, which before 0.008 keys its generator
from rand() (CVE-2025-1860), or, since 38c0f743 with Bytes::Random::Secure
installed, a constant. The original hash input is gone, so zmupdate.pl
can't rehash these, and nothing distinguishes them from properly salted
ones. Log a warning listing every user still on a -ZM- hash, noting that
it is upgraded at their next login or password reset, and that unused
accounts can be disabled or deleted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Every query on Frames filters on EventId and orders or ranges by FrameId,
and nothing needs a frame by Id. The Id primary key was a clustered index
nothing read, and EventId_FrameId_idx was a secondary index every query
used. On a copy of a local table that secondary index was half the
table's size. With (EventId, FrameId) as the primary key the index is
gone, and an event's rows are stored together, so deleting an event is a
range delete.
zm_update-1.39.36.sql:
- converts AI_Detections.FrameId from Frames.Id to the per-event frame
number, drops its foreign key to Frames and indexes (EventId, FrameId).
A composite foreign key cannot replace it: ON DELETE SET NULL would
have to null the NOT NULL EventId, and Frames rows are written in
batches, so a detection can be recorded before its frame row.
- removes duplicate (EventId, FrameId) rows, keeping the earliest.
- rebuilds Frames with the new primary key.
- removes ON UPDATE CURRENT_TIMESTAMP from Frames.TimeStamp. Any UPDATE
of a frame row was overwriting its capture time.
Each step checks the current schema first, so the migration can be
re-run.
REST API: view, edit and delete take /frames/<action>/<EventId>/<FrameId>.json.
The old single-Id URLs return 404. CakePHP 2 has no composite keys, so
the model's primaryKey is EventId. That keeps Event's dependent cascade
delete limited to the event's own frames. The controller writes with
explicit (EventId, FrameId) conditions instead of save(), which would
match rows on EventId alone. Edit no longer changes EventId or FrameId.
view=image with fid but no eid used to look up Frames.Id. It now returns
404.
The Perl Frame class is identified by (EventId, FrameId).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ajax/frames.php collected Frames.Id values from the event's rows and then
filtered with a FrameId IN (...) term, which FilterTerm translated to
Id IN (...). The search query had no EventId condition of its own. Once
the term matches on FrameId, which is only unique within an event, it
returns rows from every event, so the query is now anchored on the event
with the same WHERE EventId clause as the unfiltered list.
FilterTerm now maps FrameId to the FrameId column. The frames list
thumbnail links address the image by eid and fid instead of Frames.Id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Snapshots index/view/associations returned each snapshot's events without
a monitor check, and Snapshots and Tags add/edit attached any event ids,
so a user could add a denied monitor's event to a snapshot and then read
it back. Attaching now needs view on each event, listed events are
filtered to viewable monitors, and ids are pinned. Tags' existing
filters also match nothing, rather than everything, for a user denied
every monitor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Group membership feeds per-monitor access through Groups_Permissions, but
the classic group actions and the Groups API checked only the global
Groups permission. A Groups editor could add a monitor they are denied to
a group they have access through, or remove it from, or delete, the group
that denies it.
Add Group::canEditMembership() and require it, in both the classic UI and
the API, for every monitor added to or removed from a group, for all of a
group's monitors when it is re-parented, and for all of them when it is
deleted. The API also pins the record id and omits monitors the user may
not view from the groups it lists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LogsController::delete() never declared global $user, so its System=Edit
check always passed and anyone with System view could delete log entries.
Logs add, which ZM_LOG_INJECT opens to non-admins, could overwrite an
existing entry by Id; pin it.
ZonePresetsController had no permission checks. Reading presets stays
open to signed-in users; changing them now needs System=Edit.
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>
- index/view, Frames index and EventData index/view treated an empty
viewable-monitor list as no restriction, so a user denied every monitor
saw all of them. They now match nothing in that case.
- search and consoleEvents had no monitor filter at all and returned
events and per-monitor counts for denied monitors.
- add saved any MonitorId; it now needs view on that monitor, as editing
an event does, and cannot update an existing event named in the body.
- edit checked the event in the URL but saved the body, which could name
another event's Id or move the event to a denied monitor. Pin the id and
check a new MonitorId.
- createThumbnail and getMaxScoreAlarmFrameId were public, so routable,
and checked nothing. They are internal helpers; make them private.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AppController mapped index/add/edit/view/keyvalue/category to Crud
actions, and CrudControllerTrait answers any action a controller does not
define with them. Those generic handlers apply none of the controller's
permission or per-monitor checks: zones/view/<id> and zones/<id> returned
any zone, including those of monitors the user is denied, and Controls
add/edit and Configs add were reachable the same way. Map no Crud actions,
so an undefined action is a 404, and give ZonesController a view() that
checks the zone's monitor.
ZonesController::index() passed its monitor filter as a find() option key
rather than a condition, so it was ignored and every zone was listed. Use
a real condition.
Add AppController helpers the following fixes share: a viewable-monitor
find() condition that matches nothing when the user may view no monitor
(callers treated an empty list as unrestricted), reading a field or
associated ids from request data, and requiring view or edit on a monitor
or view on events.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ZM_AUTH_HASH_SECRET signs the JWT access and refresh tokens, and its
default is a fixed string in the public source. Nothing generated a
per-install value and tokens signed with the default verified normally,
so on an install with auth on and the secret untouched anyone could sign
an admin token and be accepted by the web UI, the API and zms.
- ZoneMinder::Config::saveConfigToDB() now replaces an empty or default
secret with 32 random bytes from /dev/urandom, hex encoded. Package
installs and upgrades run zmupdate.pl -f, which saves the config, so
existing installs get a secret on upgrade. A secret the admin set is
left alone.
- validateToken() in PHP and zmLoadTokenUser() in C++ refuse to verify
tokens while the secret is empty or the default, and the API refuses to
issue them, as it already did for an empty secret.
- zmLoadTokenUser() no longer writes the signing key to the debug log.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The filter view normalised filter[Id] only when no top-level Id was
given, and filter[...] is then applied to the filter object. With Id=1
in the URL, a crafted filter[Id] reached filter.js.php unchanged and
was echoed into a single-quoted string in the page's nonce-bearing
script, giving reflected script execution from a link.
Always normalise filter[Id], let the top-level Id win when both are
given, and escape the id where the view writes it into script and HTML.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Monitor.DefaultPlayer is a free varchar that the API saves unvalidated.
The montage, watch, zone, zones and cycle scripts echoed it inside a
single-quoted JavaScript string, so a monitor editor could store
x',p:alert(1),z:' and run script as anyone viewing that monitor, inside
the page's nonce-bearing script.
Pass every string-valued monitor field these templates emit through
validJsStr(): DefaultPlayer, StreamChannel, Janus_Pin (which comes from
the Janus server), WhatDisplay, Type, Capturing, Refresh and the initial
scale, in montage, watch, zone, zones, cycle, montagereview and event.
In montage, re-encode the stored layout Positions with JSON_HEX_TAG and
friends instead of echoing the stored JSON, since a string in it could
otherwise close the script, and escape autoLayoutName. Console's
data-stream-channel attribute gets validHtmlStr().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CakePHP's Model::set() takes the record id from a primary key in the
data passed to save(). edit() authorized the id in the URL and then saved
the request body, so Zone[Id]=<other> in the body wrote to that other
zone, past the per-monitor check just added. add() could likewise update
an existing row instead of creating one.
Add AppController::pinRequestId(), which drops the primary key from the
request data and sets the model id, and use it in these edits (pinned to
the URL id) and adds (cleared). Frames and EventData edit() never set the
model id at all, so a body without an Id inserted a new row rather than
updating; pinning fixes that too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ZonesController add/edit/delete checked only the global Monitors
permission, so a Monitors=Edit user denied a monitor could still add,
rename, reshape or delete that monitor's zones by id.
Each now requires Monitor::canEdit() on the zone's current monitor, and on
the MonitorId in the request data for add and for an edit that moves the
zone. add() rejects a request without a MonitorId.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FramesController::add() and EventDataController::add() saved request data
behind only the controllers' Events != None gate, so an Events=View user
could create rows for any event, including one on a monitor they are
denied. edit() checked the existing row but not the event or monitor the
request moved it to.
add() now requires Events=Edit and edit on the event named by EventId,
which covers that event's monitor. For EventData a supplied MonitorId must
also be viewable. edit() applies the same check to any EventId/MonitorId
in the request. Load includes/Event.php explicitly rather than relying on
the model association to have pulled it in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The classic event actions checked only the global Events permission and
then acted on whatever event ids the request supplied, so a user denied a
monitor could still change that monitor's events:
- deleteEvent() deleted when the user had Events=Edit. It now requires
Event::canEdit(), which also requires access to the event's monitor.
This covers the events form, the event form and monitor deletion.
- The events form archive/unarchive updated Events by id. Each event now
needs canEdit().
- ajax events archiveRequest() updated by id under the page-wide Events
view check. Archive now needs canView() on the event and unarchive
canEdit(), keeping the intent that viewers may archive.
- actions/event.php returned early whenever an eid was supplied, so none
of its actions ran. Fix that inverted test, and require canEdit() on the
event for rename, detail edits, archive, unarchive and delete, rather
than the global permission.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ZM_Object::set() called any method whose name matched a key in its data,
and changes() called it as a getter. That data is usually a request array
(filter[...], newMonitor[...], user[...]), so a request could invoke
save(), delete(), execute() and the like. filterdebug with fid=0 did
exactly that before its authorization check: filter[save][...] stored an
AutoExecute filter with a chosen command and filter[execute] ran
zmfilter.pl on it, giving command execution to any logged-in user. The
filter and events views pass filter[...] to set() the same way.
set() and changes() now dispatch a key to a method only when the key is
a field in $defaults or is listed in the class's new static $setters, the
accessors outside $defaults that take a value (Filter's query accessors,
Monitor::Model/Manufacturer/Groups, User::Role, and so on). Other method
names are refused with a warning.
filterdebug also requires Events view before it builds a filter from the
request.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
712994b19 stopped writing sessions for requests that arrive without a
ZMSESSID cookie unless zm_session_persist() marks them as being issued.
Web login goes through zm_session_regenerate_id_login(), which marks the
session, but the legacy API login with stateful=1 authenticates via
validateUser() and only calls zm_session_start(). A client logging in that
way without a cookie was handed a ZMSESSID cookie for a session that was
never stored, so its following cookie-only requests were unauthenticated.
Mark the session persistent once the stateful login has a user.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getNearEvents compared only the sort column with >= / <= and excluded the
current event, using Id solely as a secondary ORDER BY. When several events
share a sort value - most often StartDateTime, which has one-second
resolution, with multiple monitors recording in step - the later event of
a tie chose the earlier one as its Next. Event playback, gapless or not,
then looped between the two and the Next button could not escape.
Compare the full (sort value, Id) tuple so ordering is strict in both
directions. Read the current event's sort value through the same
Events/Monitors join as the searches, which also makes Prev/Next work
when sorting by MonitorName (the value was previously looked up in the
Events row and was always NULL).
There is no need to duplicate #progressBar in the dark theme.
Furthermore, after the level graph was added, the height of #progressBar was adjusted in the base theme but not in the dark theme, resulting in incorrect rendering.
These three blocks stacked under the event video and each took a full
line. Wrap them in a new #eventControls flex row: monitor name left,
transport buttons centred, replay status right, wrapping onto separate
lines when the viewport is too narrow.
The span#rate was never closed, so #progress, #currentTime, #zoom and
#fps were nested inside it. Close it, otherwise the whole status line
lays out as a single flex item.
The three children are flex containers themselves so their contents
centre vertically while the boxes stay stretched to the row height.
scaleToFit() measures the bottom edge of #replayStatus to decide how
tall the video can be, and stretching keeps that edge at the bottom of
the row, so auto scaling is unchanged.
The new rules are scoped under #eventControls, which outranks the
duplicated single-id rules in the classic and dark event.css.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tag input and its prev/next buttons spanned the full width of the
event view above the video row. Move the tags-container into the
eventStats column and narrow that column from col-sm-4 to col-sm-3 so
the video gets the width back.
- tags-container drops its 1rem side margins, which were sized for a
full-width bar, and gains bottom spacing to separate it from the
stats table.
- tag-dropdown gets position: relative so the suggestion list anchors
under the input. It had no positioned ancestor before, so in the
narrower column the absolutely positioned list would have landed in
the wrong place. It also gets a min-width so the input stays usable
once tag chips wrap.
- tag-dropdown-content sizes to its items rather than to the narrow
input.
- tag-input can shrink inside the flex row instead of forcing overflow.
event.js toggles the video wrapper between the stats-visible width and
col-sm-12 when the stats column is hidden, so update those six places
to col-sm-9 as well.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
icons is the toolbar icon map that thirteen view scripts, and logpanel.js,
pass to bootstrapTable(). It was defined in skin.js, which xhtmlFooter()
loads last of all, after every view's own script. A client that loads the
whole page is fine, because skin.js still runs before DOMContentLoaded,
but one that stops short -- a crawler that caps resource loading, an
aborted load -- runs the view's ready handler against an undefined icons
and the table never initialises:
Uncaught ReferenceError: icons is not defined
at HTMLDocument.initPage (.../views_js_console-....js:458:12)
Move it to web/js/table-helpers.js, which already exists to hold what the
bootstrap-table views share and is loaded well before them, and export it
alongside the other helpers for the node tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CSRF token check on the image proxy only runs when ZM_ENABLE_CSRF_MAGIC
is on. Browsers that send Sec-Fetch-Site report when another site started a
request, so refuse proxy requests whose value is anything but same-origin or
none, whatever the CSRF setting. Browsers that do not send the header are
unaffected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Event::GenerateVideo() built the ffmpeg command as one string and ran it
through escapeshellcmd(), which leaves spaces alone. Any value in the string
could therefore add ffmpeg arguments. The event Name is already reduced to a
safe filename, but DefaultVideo and the transforms were not.
The command is now a list with every argument passed through escapeshellarg(),
the same approach as the Perl GenerateVideo. The configured input and output
option strings are split on whitespace, as the Perl side does. ffmpeg output
goes to ffmpeg.log through proc_open descriptors: escapeshellcmd() was escaping
the old '> ffmpeg.log 2>&1' redirect, so it was passed to ffmpeg as arguments.
An empty transforms string no longer adds a bare -vf.
Checked with a stub ffmpeg that records its argv: a Name and a DefaultVideo
carrying shell and option payloads arrive as single literal arguments and
nothing runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fileSize() joined the stored DefaultVideo onto the event directory as is,
so an older row, or one written before the model validation was added,
holding ../ could stat a file outside it. fileExists() above already
applies basename(); do the same here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DefaultVideo can be set by any user with Events=Edit through the API
(EventsController add/edit), and readers join it onto the event path:
view_video.php streams it, image.php extracts frames from it,
findVideoEventFile, Event::getStreamSrc/FileSize and the API fileExists
check all use Path().'/'.DefaultVideo. A value such as ../../x pointed
those reads at arbitrary files readable by the web account.
Reject DefaultVideo values on save in the API model unless they are a
bare filename (no / or \, no NUL, not . or ..). Make the PHP Event
DefaultVideo() accessor return basename() so every web reader only ever
looks inside the event directory, even for rows written before this
check, and apply basename() to the raw array read in the API model's
fileExists.
Event::GenerateVideo built its output filename from Name with only
whitespace replaced; Name is editable through the event rename and
eventdetail actions. Replace anything outside [-A-Za-z0-9_.] and a
leading dot so the output stays inside the event directory.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit f3f679b644db28b4b1bff79d5dd98eca24395fc4)
index.php skips csrf_check() for view=image because images are loaded
through <img src>, and csrf_check() only validates POSTs in any case. The
proxy= handler makes the server fetch a caller-supplied URL, so a page
on another site could embed an <img> pointing at it and have a logged-in
monitor editor's browser drive server-side requests to the LAN.
When ZM_ENABLE_CSRF_MAGIC is on, the proxy branch now checks
__csrf_magic from the request with csrf_check_tokens() and answers 403
without it. Plain image views are unaffected. The only caller, the
camera discovery thumbnail on add_monitors, appends csrfMagicName and
csrfMagicToken to its <img> URL, and now URL-encodes the camera stream
URL so a stream URL containing & or " no longer truncates the proxy
parameter or breaks out of the src attribute.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 2958f89c5af63040483ac8a389d1fd7080b777a7)