* fix: schedule ONVIF subscription renewal for short and unreported lifetimes refs #5179
The Beward camera from #5165 grants a 60 second subscription and its
RenewResponse carries no TerminationTime. Three things combined so that
ZM renewed after every PullMessages that returned messages (61 renewals
in 74 seconds of the reporter's log):
- The renewal was scheduled a fixed ONVIF_RENEWAL_ADVANCE_SECONDS (60)
before termination, which for a 60 second subscription is its creation
time. ONVIFNextRenewalTime() now uses the smaller of that advance and
half the remaining lifetime.
- Renew() only logged a missing TerminationTime and left
next_renewal_time in the past. assume_renewal_times() now schedules
from the lifetime we asked for, capped at what the camera last granted
(ONVIFAssumedLifetime()), so this camera renews every 30 seconds.
- IsRenewalNeeded() was only checked after a response carrying messages,
so a camera quiet for longer than its subscription was never renewed.
It is now also checked after an empty (SOAP_EOF) poll.
Since renewal is now checked after every poll, a camera that answers
Renew with ActionNotSupported has renewal disabled rather than being
asked again on each poll, matching the existing handling of unusable
TerminationTimes.
Tests cover the renewal time for long, short and very short
subscriptions, and the assumed lifetime with no, smaller and larger
previously granted lifetimes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: only disable ONVIF renewal for ActionNotSupported, keep sub-second grants refs #5179
Review follow-ups on the renewal scheduling change:
- Renew() treated soap->error == 12 as ActionNotSupported, but 12 is
gSOAP's generic SOAP_FAULT. With renewal now disabled on that path, a
NotAuthorized or InvalidArgVal fault from Renew would have stopped
renewal for good while marking the subscription healthy.
ONVIFIsActionNotSupported() checks the fault subcode and string for
ActionNotSupported (wsa: or ter:); any other fault now takes the
existing cleanup and re-subscribe path.
- granted_lifetime truncated the remaining time to whole seconds. A
termination less than a second away stored 0, which reads as "never
reported", so the next Renew without a TerminationTime assumed the full
requested 300 seconds. ONVIFGrantedLifetime() rounds up instead.
Tests cover ActionNotSupported subcodes and strings against other faults
and non-fault results, and the granted lifetime for whole, fractional and
sub-second remainders.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: count an assumed ONVIF renewal lifetime from the request, not the response refs #5179
When a RenewResponse has no TerminationTime, assume_renewal_times()
started the assumed lifetime when the response arrived, but the camera
counts it from when it received the request. A slow response pushed the
deadline and the next renewal later by the response time: with
subscription_timeout=10 and a 6 second response, the camera's deadline
was request+10 but the renewal was scheduled for request+11. In absolute
renewal mode it also ignored the exact deadline that was sent.
Renew() now records the time just before building the request and the
deadline it asks for: request time plus subscription_timeout, or the
absolute whole-second time it sends. ONVIFAssumedTermination() replaces
ONVIFAssumedLifetime() and returns that deadline, capped at request time
plus the lifetime the camera last granted. The renewal is scheduled from
the request time; if the response arrived after it, it is already due and
the next poll renews.
Tests cover no, smaller and larger previous grants, an absolute deadline
kept exactly, and the slow-response case from review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>
ZM asks the camera to hold each PullMessages for up to pull_timeout
seconds (default 1). The Beward camera from #5165 answers in 10-30ms with
no messages, which gSOAP reports as SOAP_EOF, and Run() issued the next
request at once: ~2,500 PullMessages in 74 seconds of the reporter's log
(~34/s), each with a fresh UsernameToken digest.
Time each PullMessages. When it comes back with no messages (SOAP_EOF, or
a SOAP_OK response with no NotificationMessage) before the Timeout,
WaitForMessage() now waits out the remainder in 100ms steps, staying
responsive to terminate_ and zm_terminate. A camera that ignores the
long-poll is then polled at most once per pull_timeout; one that holds it
is polled again immediately as before. Events raised during the wait stay
queued in the camera's pull point and arrive with the next request, so
the added latency is at most pull_timeout.
ONVIFEarlyPollWait() computes the remainder and is tested for an
immediate answer, a full hold, a partial hold and sub-millisecond
elapsed times.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Per-topic alarm expiry takes each alarm's deadline from the
PullMessagesResponse TerminationTime. Beward cameras send their
CurrentTime as the TerminationTime in every response, so an alarm was
stored with a deadline that had already passed and the sweep at the end
of the same WaitForMessage() pass removed it about 25us later. The
analysis thread checks onvif->isAlarmed() once per frame, never saw the
monitor alarmed, and no event was recorded even though the log showed
"ONVIF Triggered Start Event".
Move the TerminationTime handling into a free function,
ONVIFAlarmTermination(), which applies the camera clock offset as before
and returns false when the adjusted time is not after now. The alarm then
gets no expiry and is cleared by the camera's explicit State=false
message, as it was before per-topic expiry was added. The Debug line now
also prints CurrentTime so this camera behaviour is visible in logs.
Tests cover TerminationTime equal to and before CurrentTime, a future
TerminationTime, a skewed camera clock, a missing CurrentTime keeping the
previous offset, and a missing TerminationTime.
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>
#5174 reports an event's m3u8 as invalid and proposes moving every fragment's
byte range 8 bytes further into the file, from @17203 to @17211.
That reading comes from ffprobe's trace, whose second column is the offset of
the box *body* -- the start plus the 8 byte box header -- not the start. In
the manifest quoted there the first fragment is 1434876@17203, and the trace
has the moof body at 17211 and the mdat ending at 1452079, so the range spans
exactly moof(264) + mdat(1434612) = 1434876 from the start of the moof. The
proposed change would cut the moof header off every segment.
The manifest is not contiguous, which is what draws the eye: the init range
is ftyp+moov and the fragments start 16KB later, because reserve_region puts
the leading sidx in between and the sidx has to END where the fragments
BEGIN. Nothing fetches those bytes and HLS does not require byte ranges to
abut.
So assert it rather than argue it. Against sidx-moof.mp4, using the box
walker the sidx tests already keep for the purpose, check that a fragment
starts at its moof box rather than 8 bytes in, that fragments run back to
back, that the init range is the header boxes and nothing else, and that the
gap between them is exactly the reserved index.
This says nothing about the browser error that prompted the report, which is
not quoted in the issue; it only rules the byte ranges in or out.
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>
writeM3U8 truncated and rewrote the whole playlist every time a fragment
completed. That is O(fragments) of writing per fragment, so an event pays
O(fragments squared) overall. For the ten minute events a section length
produces the manifest is about 55KB and the cost is invisible. It stops being
invisible when an event does not close.
On a box here, four monitors stopped closing their events after a reboot and
ran for 51 hours. Their manifests reached 17MB and 470,000 lines, and strace
showed where the writes were going: in one window the mp4 took 3 writes while
index.m3u8 took 195, opened O_WRONLY|O_CREAT|O_TRUNC. The cameras produced
0.4MB/s of video between them and the disk was absorbing 24MB/s at 100%
utilisation, 106ms average write latency.
That is a loop rather than just waste. One rewrite took 3.48s of wall clock
against a fragment arriving every 1.2s, so the event thread could never catch
up, the packet queue stayed full ("Analysis is not keeping up" every three
seconds), and the analysis thread is where the section length check that would
have closed the event lives. The growth starved the only thing that could stop
it.
An EVENT playlist is append only: a fragment's three lines never change once
written, and only the header depends on anything global. So write the new
fragments to the end, and fall back to a full rewrite when something above
them would differ -- a changed target duration or init segment end, the
different url the close path passes, a different path, or the closing
ENDLIST. The remembered byte count is checked against the file before
appending, so a manifest that something else has truncated, replaced or
removed is rebuilt rather than appended to; any failure zeroes the state,
which makes a rewrite the answer to anything unexpected.
The header and fragment text are split into m3u8Header, m3u8Fragment and
m3u8TargetDuration so the property that matters can be tested without
standing up a VideoStore: appending one fragment at a time produces a byte
identical manifest to writing the whole thing at once.
Co-Authored-By: Claude Opus 5 (1M context) <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>
finalize() located the mfra by reading the trailing mfro with fopen and
fseeko and decoding it by hand, the same job zm_mp4::media_end() does for
the sidx scan. Use media_end() for both, so the last HLS fragment and the
index agree on where the media ends.
media_end() is also stricter: it requires an mfra box of the stated size
at the offset the mfro points to, where the old code accepted any size up
to the file length. A trailer that does not check out now leaves the final
fragment running to EOF, as a missing trailer already did.
A new test covers media_end() against the fixture's real mfra and three
damaged trailers: an mfro size off by four, one larger than the file, and
no mfro at all.
refs #5144
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build_sidx_region() wrote starts_with_SAP=1, SAP type 1 into every
reference. That holds for frag_keyframe recordings, where every fragment
opens on a keyframe, but a monitor whose encoder options add frag_duration
or frag_size gets fragments cut mid-GOP, and the index then claimed a
decodable start where there was none.
scan_fragments() now reads the sync flag of each fragment's first video
sample: trun first_sample_flags, else the first entry's sample_flags, else
the tfhd default_sample_flags, else the trex default (read_video_track()
now keeps it). A reference with no SAP gets 0 for starts_with_SAP, type and
delta; a merged reference takes the flag of its first fragment. parse_traf()
also skips tfhd default_sample_size, which it never needed before.
Tests: remuxing the fixture with frag_keyframe and a 250 ms frag_duration
gives about a dozen references, of which exactly the three that begin at a
keyframe are SAPs; the frag_keyframe fixture scans as all SAPs, and the
byte-for-byte comparison with the reference tool is unchanged; merging takes
the first fragment's flag. The remux sequence the muxer test used is now a
helper shared by both.
refs #5144
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every fragmented event reserved 64 KiB for its leading sidx, whatever its
length. On a short motion clip of 1-2 MB that is 3-6% of the file.
zm_mp4::reserve_size() sizes the region for twice the fragments expected
in one section: section length divided by the GOP, which is the encoder's
gop_size when encoding and the packet queue's longest keyframe interval
otherwise, over the capture fps. It rounds up to whole 4 KiB blocks and
stays between 4 KiB (337 references) and the previous 64 KiB, which is
still taken when the section length, GOP or fps is unknown. The default
600 s section at a 1 s GOP now reserves 16 KiB.
A low guess does not lose the index: an event with more fragments than
the region holds has neighbouring fragments merged into one reference,
as before, which only coarsens seeking. VideoStore keeps the size it
reserved so finalize() fills that exact region, and Monitor gains a
GetSectionLength() accessor.
refs #5144
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cc382b040 made the non-Linux fallback of sync_range() call fdatasync(),
which macOS's unistd.h does not declare, breaking the macOS build:
zm_mp4_sidx.cpp:120:10: error: use of undeclared identifier 'fdatasync'
Use fsync(), as the code did before that commit and which built on macOS
in #5145's CI. Linux still syncs only the region with sync_file_range().
refs #5144
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
write_leading_sidx() called fsync() twice when an event closed. The first
wrote back every dirty page of the event file -- the whole recording is
usually still in the page cache at close, often hundreds of MB -- only to
order the 64 KiB region body before its 8-byte header. On a 400 MB file
on NVMe the two calls took 0.24 s; on a disk writing 100 MB/s it is about
4 s per event, and closeEvent() joins the previous close thread, so
back-to-back events could stall analysis.
On Linux, sync_file_range() now writes back just the region body before
the header goes in, which takes under a millisecond for the same file. It
waits for the write-back but does not flush the drive cache. Other systems,
and filesystems that reject the call, fall back to fdatasync().
The sync after the header is dropped. If that write is lost, the region is
still the free box it was, so durability of the header is not needed for a
valid file; the old code also logged "cannot flush" and reported failure
for an index that had been written.
refs #5144
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
VideoStore::open() reserves a 64 KiB free box between moov and the first
fragment, but only when the MOV muxer wrote the moov in the header and
moof+mdat fragments follow it. finalize() scans the fragments and fills the
region with free padding followed by a sidx that ends at the first moof, so
FFmpeg-based players (Chromium, Android WebView, Electron) treat the index as
complete and start playback without visiting every fragment. On any parse or
write failure the region stays a free box.
fixes#5144
51e8a4571 dropped libcurl4-gnutls-dev from the runtime Depends, relying on
${shlibs:Depends} for the library. zoneminder-containers/zoneminder-base
builds its runtime dependency package from the control file text with
equivs, where ${shlibs:Depends} is never expanded, so the image lost
libcurl-gnutls.so.4 and zmc/zma exited with status 127.
Add libcurl3t64-gnutls | libcurl3-gnutls, the runtime library the build
links against (libcurl4-gnutls-dev is the first Build-Depends choice).
It is a library package, so it does not conflict with
libcurl4-openssl-dev the way the -dev package did.
Refs zoneminder-containers/zoneminder-base#89
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_event_start_packet_it() logged "Hit end of packetqueue before
satisfying pre_event_count" at Debug when packet->image_index was below
pre_event_count, assuming that meant zmc had just started. image_index
comes from shared_data->image_count, which is not reset when a capture
reconnect runs Monitor::Pause() and clears the packetqueue. An event
opened on the first frames after a reconnect therefore logged a Warning
for a short pre-event buffer that cannot be avoided.
Record the queue_index of the first packet queued since the last clear()
and log at Debug when the walk back stops on that packet, i.e. nothing
has been trimmed since startup or the reconnect. A Warning now means
packets really were removed below the pre-event count.
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>
zms checked access against the monitor id in the query string and then
streamed the event id from the same query, loading the event's real
monitor later without checking it. A viewer allowed monitor 1 and denied
monitor 2 could fetch a monitor-2 event by sending monitor=1, or by
leaving monitor out, which checked only the global Events permission.
For event requests, look up the event's MonitorId and require Events
view plus access to that monitor. When the stream is chosen by monitor
and time instead of by event id, the named monitor is the one streamed
and is the one checked. Moving to another event during playback stays
within the same monitor, so checking the first one is enough.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ReadJpeg() stored the JPEG header's width and height in the Image before
calling WriteBuffer(). WriteBuffer() reallocates only when the requested
size differs from the current one, so it saw no change and kept the
existing buffer and linesize, and the scanline loop then decoded every
row of a larger file past its end. A File monitor re-reads its source
into a monitor-sized image on every capture, so whoever can write that
file could overflow zmc's heap. Stored event JPEGs read by zms take the
same path.
Leave width and height to WriteBuffer(), as DecodeJpeg() already does.
FileCamera::Capture() also now refuses a file whose dimensions do not
match the monitor, since everything downstream is sized for the monitor.
Add a Catch2 case that reads a 256x192 JPEG into a 64x48 image. It
segfaulted before this change and passes after.
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>