generateAuthHash() cached every hash under the IP-keyed slot
'AuthHash'.$remoteAddr, but the value was IP-bound only when $useRemoteAddr was
set. getZmuCommand() calls generateAuthHash(false, true), which wrote an
IP-less hash into the IP-bound slot and reset AuthHashGeneratedAt. Because the
status poll runs getZmuCommand (web/ajax/status.php) right before emitting the
auth hash, the poll then served the IP-less hash to the browser; the next
IP-bound request was rejected by the validator, redirecting the user to login
roughly every poll. This happens with a completely stable client IP, so it is
distinct from the IP-rotation case.
Key the cache slot by the address actually baked into the value: only use the
session address when this caller asked for it (and AUTH_HASH_IPS is on). IP-less
and IP-bound hashes now occupy separate slots and can no longer clobber each
other.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tablename field of a filter term was copied verbatim into SQL by
sql_attr(), while attr, op, val and collate were all sanitized. An
authenticated user with Events View permission could inject SQL via the
filter[Query][terms][N][tablename] request parameter, enabling blind
read access to the whole database (password hashes, camera credentials).
Restrict tablename to the table aliases actually used by the filter
queries (E, M, S, F, T, ET, Snapshots); reject anything else, log it,
and fall back to 'E'.
Refs GHSA-q2w3-h644-f8xq
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- API (database.php.default): only set the PDO verify flag when SSL is
actually configured (ZM_DB_SSL_CA_CERT set), matching the web/Perl/C++
layers. Previously a fresh install's default (1) would set the flag on a
non-SSL connection, since the CakePHP datasource merges 'flags' uncondi-
tionally.
- Both PHP layers: cast to string and trim before parsing the value, and use
strict in_array, to avoid type-juggling and stray-whitespace edge cases.
- zm_db.cpp: use my_bool (not char) for the MYSQL_OPT_SSL_VERIFY_SERVER_CERT
fallback argument, the type libmysqlclient expects. That branch only
compiles on older clients without MYSQL_OPT_SSL_MODE, where my_bool exists.
refs #3816
Addresses review feedback on #4223:
- The IS NOT Odd/Even branch emitted '% 2 = ' (identical to IS), so
'MaxScore IS NOT Odd' matched odd values instead of even. Emit '% 2 != '
so the negation is correct.
- Reword the comment to reflect that IS is only preserved for NULL here;
any other value (including TRUE/FALSE) compares for equality.
refs #4223
A filter term such as 'Score IS 2' or 'Score IS NOT 2' emitted literal
SQL 'Score IS 2', which is invalid - IS / IS NOT only accept
NULL/TRUE/FALSE in MySQL. Keep IS/IS NOT only when the value is NULL
(and the existing Odd/Even modulo handling); for any other value emit
= / != so the term is valid and matches as the user intended.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a ZM_DB_SSL_VERIFY_SERVER_CERT setting so a database connection that uses
ZM_DB_SSL_CA_CERT can talk to a server with a self-signed or otherwise
non-matching certificate. When enabled, verification is by identity (the cert
must chain to the CA and its CN/SAN must match ZM_DB_HOST), consistent across
the C++ daemons, the PHP web interface, the CakePHP API and the Perl scripts.
This re-does the reverted #3817. That PR broke the build because it called
mysql_options(MYSQL_OPT_SSL_VERIFY_SERVER_CERT, ...), and that enum was removed
from the MySQL 8.0 C client in favour of MYSQL_OPT_SSL_MODE; it also passed a
c_str() where a my_bool* was expected, and referenced the PHP constant
unconditionally (fatal on PHP 8 for an upgraded install whose zm.conf predates
the option).
The option that controls server-cert verification differs by client library and
the symbols are enum values, not macros, so CMake feature-detects them by
compiling:
- HAVE_MYSQL_OPT_SSL_MODE (MySQL 5.7.11+/8.0, MariaDB Connector/C 3.1+)
- HAVE_MYSQL_OPT_SSL_VERIFY_SERVER_CERT (older MariaDB/MySQL)
zm_db.cpp uses SSL_MODE_VERIFY_IDENTITY / SSL_MODE_REQUIRED when the former is
available, else falls back to the latter with a proper my_bool.
Value handling is three-way in every layer: a truthy value verifies, a false-y
value (0/false/no/off) skips verification, and an empty/unset value leaves the
client default in place so existing installs are unchanged on upgrade. PHP, the
API datasource (via PDO flags) and the Perl DSN are all guarded with defined()
checks. Fresh installs default to 1.
Documents the full ZM_DB_* connection and SSL settings, including the hostname
verification gotcha when connecting by IP, in docs/userguide/configfiles.rst.
refs #3816
Event::ThumbnailWidth()/ThumbnailHeight() derived the secondary dimension
through an integer SCALE_BASE scale:
$scale = intval((SCALE_BASE * THUMB_WIDTH) / Width);
ThumbnailHeight = reScale(Height, $scale);
For a high-resolution monitor the scale factor is small and intval()
truncates it. e.g. a 2688x1520 monitor with WEB_LIST_THUMB_WIDTH=48 gives
scale = intval(100*48/2688) = intval(1.78) = 1, so the height becomes 15
instead of 27 — a 3.2:1 thumbnail instead of 16:9. The events-list hover
overlay sizes its container from that thumbnail and object-fit:cover-crops
the real (correct-aspect) video into the squashed box, which shows as a
"long narrow" popout.
Compute the secondary dimension directly from the aspect ratio
(round(Height * THUMB_WIDTH / Width)) so no precision is lost.
refs #3443
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Janus_Pin() interpolated $this->Id() directly into the zmu command
string passed to shell_exec(), while the sibling AlarmCommand() path
already routes the same value through validCardinal() before building
its command. Bring the two into line so every Id reaching a shell sink
is validated as a cardinal number.
Id is the monitor's integer primary key and is not user-controlled, so
this is a consistency and defense-in-depth change rather than a fix for
an exploitable issue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On successful login auth.php called zm_session_clear() followed by
zm_session_regenerate_id(), which together emitted three Set-Cookie
ZMSESSID headers: a deletion, a throwaway intermediate id, and the final
authenticated id. This is the multiple-cookie behaviour reported in #2471.
Add zm_session_regenerate_id_login(), which clears the pre-auth session
data and calls session_regenerate_id(true) to issue one new id while
deleting the old session server-side. Same anti-session-fixation
guarantee in a single Set-Cookie. Logout (zm_session_clear) and the
periodic mid-session regeneration are left unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The PHP shared memory map in web/includes/Monitor.php was not updated
when last_analysis_viewed_time was added to SharedData (zm_monitor.h,
offset +152), so every field from control_state onward was read 8 bytes
early: control_state, alarm_cause, video_fifo, audio_fifo and janus_pin.
The TriggerData base offset (864) had been updated, so only the
SharedData tail was skewed. The error goes undetected because connect()
only checks the 'valid' byte at +88, which sits in the unaffected
region, and never compares the 'size' field.
Also corrects the TriggerData showtext offset: 'text' is 256 bytes at
912, so 'showtext' starts at 1168, not 1268.
The Perl map (Memory.pm.in) computes offsets from the field sequence
and is unaffected.
Tests: php -l passes; offsets verified field-by-field against the
SharedData struct in src/zm_monitor.h on master.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a CanIndicatorLight capability and status-aware Indicator toggle button. The indicator LED on the ASH21-B, ADC2W and ASH42-B is controlled via the LightGlobal config (configManager get/setConfig); add indicatorLightOn/Off/Status to Dahua_RPC and a model-specific 'Amcrest ASH42-B RPC' Controls row, with the capability also enabled on the ASH21-B/ADC2W/generic rows. Migration zm_update-1.39.13.sql adds the column.
Add Dahua_RPC keepAlive (global.keepAlive) wired into a 30s zmcontrol idle tick, plus session-expiry re-login retry in set_config and the status queries, so the long-running control daemon does not silently fail after the ~60s session timeout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a CanLight control capability rendering a single status-aware Light toggle button. The ADC2W white light is driven via CoaxialControlIO.control (Type 1, numeric IO); the button queries live state and reflects it (amber when on).
To get device state to the browser, add an opt-in two-way response path to the control protocol: zmcontrol writes a JSON result back only when a request sets wants_response (fire-and-forget commands unchanged, SIGPIPE-safe); Monitor::sendControlCommandWithResponse and ajax/control.php return it.
Also adds get_config/set_config/probe to Dahua_RPC for characterising cameras, the CanLight column (migration zm_update-1.39.12.sql), edit-UI checkbox, and a config unit test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The event filter "Limit to first N" stopped being honored in 1.38. Using
"List Matches" on a filter that should return N events returned all
matching events instead. This was a regression from 1.36, triggered by
the Tags feature.
Root cause chain:
1. The events view adds a default, empty `Tags` term to unsaved filters.
That term is invalid (empty value) so Filter::sql() correctly omits it
from the WHERE clause, but pre_sql_conditions()/post_sql_conditions()
counted it regardless of validity.
2. Tags was classified as a post-sql condition, even though it is filtered
in SQL (sql_attr() returns T.Id, with EXISTS/NOT EXISTS for any/no tag)
and its post-sql test() was a no-op stub. So the empty Tags term made
has_post_sql_conditions true.
3. has_post_sql_conditions being true disabled the SQL LIMIT fast-path in
ajax/events.php, leaving the PHP-side trim as the only limit enforcement
-- and that trim used an inverted comparison (limit > row count), so it
never trimmed when there were more rows than the limit.
Fixes:
- Filter::pre_sql_conditions()/post_sql_conditions() now require
term->valid(), so empty default terms no longer count as conditions and
the SQL LIMIT fast-path stays enabled.
- FilterTerm::is_post_sql() no longer returns true for Tags, since Tags is
handled in SQL.
- ajax/events.php filter-limit trim comparison corrected to '<' so the
limit is applied when there are more matches than the limit (matches the
pagination trim below it). This covers genuine post-sql filters such as
ExistsInFileSystem.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- When executing Event->Length() , get the duration of the recorded event file if the duration in the database table is 0
- If native HLS playback fails in Safari, we first try playing MP4, and only if MP4 playback fails do we switch to MJPEG playback
zmfilter and the web filter UI generated SQL like
to_days(E.StartDateTime) = to_days('2026-05-06 09:42:56')
which prevents MySQL from using the StartDateTime index, forcing a
full table scan. With many filter daemons against a large Events
table this saturates mysqld and makes the system unresponsive.
Rewrite the SQL generation in ZoneMinder::Filter (Perl) and
ZM\FilterTerm (PHP) so Date/StartDate/EndDate attrs emit range
expressions against the underlying datetime column:
E.StartDateTime >= '2026-05-06 00:00:00'
AND E.StartDateTime < '2026-05-07 00:00:00'
Covers =, !=, >, >=, <, <=, IS, IS NOT, IN, NOT IN, and the
CURDATE()/NOW() values (which use INTERVAL 1 DAY for the upper
bound). EXPLAIN now reports type=range on Events_StartDateTime_idx
where it previously reported type=ALL.
CurrentDate (the constant left-hand expression to_days(NOW()))
keeps its existing form since it does not touch the indexed column.
Add Perl and PHP unit tests under tests/perl/ and tests/php/
exercising the generated SQL across operators.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>