HikVision.pm calls XML::LibXML->load_xml and XML::LibXML::Element->new but
never loaded the module, so it only worked when another module had already
pulled it in. Add the missing use line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plain relay keeps the user's password in the web session and puts
user= and pass= in every stream, video and HLS URL the web UI builds,
in multi-server requests and in zmu command lines. hashed has been the
default for a long time and does none of that.
Mark plain as deprecated in the option's description and help, and
have zmpkg.pl log one Warning at start when authentication is on and
the relay is plain, pointing at hashed with ZM_AUTH_HASH_SECRET set.
The warning is logged once per start rather than per web request so it
does not flood the log. The option's values and behaviour are
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 9ccb904bca)
Two ways a System=View account could read secrets through the Config API.
The oracle: ConfigsController::index() passed the caller's named filter to
buildFilter() and returned the matched rows. redactSecret() blanked a secret
Value in the response but not in the WHERE clause, so a filter such as
Name:ZM_AUTH_HASH_SECRET/Value LIKE:a% returned the row for a correct prefix
and nothing otherwise - the row count leaked the secret a character at a time,
and that secret signs auth tokens. index() now detects a filter on the Value
column and drops the secret rows from the result, so their presence reveals
nothing; a filter on any other column is unaffected.
The flags: ZM_JANUS_SECRET, ZM_UPLOAD_PASS and ZM_MQTT_PASSWORD held secrets
but were not marked Private, so the API returned them to System=View and
skin.js.php wrote them into every page as JavaScript constants. Mark them
private in ConfigData.pm.in (fresh installs) and add a Config UPDATE to the
untagged 1.39.41 migration (existing installs). No web page reads the three;
zmfilter, zmc and the Janus integration read them server side.
Tested: tests/php/test_config_value_filter.php (11 cases) covers the Value
detection, including Model.Value, case, and the DefaultValue non-match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both cameras found on this network kept accurate UTC with no timezone set, so
their clocks were five hours out on the overlay while the recordings
themselves were correctly stamped. The factory daylight saving rule was also
nonsense everywhere: thirty minutes, March to September.
set_time points the camera at an ntp server and sets its timezone, with
optional summer time rules. The timezone is derived from a UTC offset rather
than hardcoded: the vendor's list starts at GMT-12:00 and steps every thirty
minutes, so -6 becomes index 12, which its own language pack labels
"(GMT-06:00) Central Time (US&Canada)". An offset with no index, a quarter
hour zone say, is refused instead of silently rounded.
These settings live in flash and set_time is meant to be safe to re-run, so
nothing is written unless it differs. Verified against a camera: an unchanged
call issues no write at all, a changed server issues exactly one, and
restoring it issues one more. The comparison recurses into the nested start
and end rules, which would otherwise always differ as hash references, and
compares as strings because the camera returns numbers and JSON booleans
interchangeably.
dstType is deliberately not sent. Supplying week based rules makes the camera
set it itself, and it rewrote the value we sent, so sending one would mean a
read back that never matches what was written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
Controls entry for the Herospeed / LS Vision web api protocol, as both a
migration for existing installs and a controls.sql row for fresh ones.
Capabilities match what /api/image/ptz-ctrl actually offers: continuous move
in eight directions, zoom, focus with autofocus, iris, 255 presets with a home
and the ability to store them, reset and reboot, and the white floodlight.
CanMoveAbs, CanMoveRel and CanMoveMap stay 0 - the api has no absolute,
relative or map move. Speeds are 1-8 because the device advertises no range
anywhere, so the module clamps to the window every optType was observed to
accept.
Also fixes the message added with the can() guard: "monitor $id's" parses as
$id::s, the old package separator, so the monitor id silently vanished from
the one line whose job is to say which monitor to go and look at. Found by
reading the message the running daemon actually emitted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
Replaces the ONVIF subclass with a standalone protocol. ONVIF stays as it is
and remains the right choice for these cameras when only pan, tilt, zoom and
imaging are wanted; this exists for what ONVIF cannot reach on them.
The full ptz surface now goes through /api/image/ptz-ctrl, whose optType
values are taken from the web ui's PTZ_CONTROL_CMD_* constants: the eight
move directions and stop, zoom, focus including one-key autofocus, iris, auto
pan, lens initialisation, and presets by index and name. /api/image/ptz-info
adds pan, tilt and zoom position read-back, which ONVIF does not offer here.
The white supplement floodlight is dayNightMode.ledMode in
/api/image/image-param, switched between Warm Light and IR Light. ONVIF
exposes no light at all on this hardware - both GetRelayOutputs and the PTZ
auxiliary Wiper command are accepted and neither does anything measurable.
Login is the camera's five round handshake, with the session cookie returned
in the response body rather than a Set-Cookie header. The rounds are pure
functions and a captured exchange is replayed by a test: each one hashes the
raw bytes the previous hex digest names rather than its text, and getting
that wrong produces an equally plausible digest whose only symptom is a
refused login. Code 101 means the session expired, so every call re-logins
once and retries rather than dropping an operator's keypress.
Speed is clamped: the device advertises no range anywhere in its api or ui.
Preset indexes are validated before being sent for the same reason.
Standing alone also means no imports are inherited. Missing ones are invisible
to perl -c and only fail on the path that uses them, so the tests assert the
required modules are actually loaded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
These identify themselves as Manufacturer "NONE", Model "IPCamera" over
ONVIF; the real name is in the ONVIF scope (name/L5TW), in the vendor api's
/system/device-info, and in the 2C:6F:51 OUI.
PTZ, presets and imaging are inherited from the ONVIF module, which the
camera implements well - absolute, relative and continuous move, 256 presets,
brightness through sharpness, and motion and tamper events.
What ONVIF does not expose is the white supplement floodlight the deterrence
feature uses. GetRelayOutputs and the PTZ auxiliary Wiper command are both
accepted by the camera and both do nothing measurable. The light lives in the
vendor REST api at /api/image/image-param, as dayNightMode.ledMode, switched
between Warm Light and IR Light.
Reaching that api needs its login handshake: sha256 over
username+salt+base64(timestamp)+password, rehashed over the raw digest bytes
with the challenge appended, optionally iterated, with the session cookie
returned in the response body rather than a Set-Cookie header. The rounds are
pure functions so the captured exchange is replayed by a test; the raw-bytes
versus hex-text distinction in particular produces an equally plausible digest
when wrong, so it is pinned rather than trusted.
The section write echoes every field back, since a partial write makes the
camera drop what it was not sent.
Note the supplement light only engages while the camera is in night mode, so
setting it during the day reads back correctly but produces no light.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
zmcontrol dispatches straight onto the control object with
$control->$command(...). A monitor's actions and ui buttons are configured
independently of its control protocol, so nothing stops a LightOn action
pointing at a protocol with no lightOn, and Perl kills the control server with
"Can't locate object method".
Swapping a camera for a different make produces exactly that. A monitor whose
AMLink camera was replaced by an ONVIF one kept its LightOn and LightOff
actions, and ONVIF has neither, so the first alarm after the protocol change
would have taken the control server down for that monitor.
Check can() before dispatching, log which monitor and command need attention,
and answer a caller that asked for a response rather than leaving it hanging.
Direct protocol mode gets the same check, as a Fatal, since there the command
came from the command line and is a typo worth reporting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
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>
Perl's continue is not loop control: outside a given/when block it dies with
"Can't \"continue\" outside a when block", so renumber stopped at the first
event whose directory was missing and never handled the ones after it.
fixes#5340
zmaudit removes a filesystem monitor link that no longer points to a valid
monitor directory with executeShellCommand("$ZM_PATH_RM \"$link\""). The link
name is the monitor name. The web layer names the link from the raw request
(basename($_REQUEST['newMonitor']['Name'])) rather than the filtered name
stored in the database, so it can contain shell metacharacters that the stored
name cannot. Inside the double-quoted rm argument, $(...) and backticks in the
name are still expanded, so a user with Monitors create/edit can have zmaudit
run arbitrary shell commands when it later cleans up the dangling link.
Use Perl unlink() on the de-tainted link name: it removes the symlink itself
with no shell, so nothing in the name is interpreted.
Addresses GHSA-746p-4622-p3fr.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When building an AutoExecute command, zmfilter treats a substituted tag value
as plain (inserted into the command unquoted) when it matches
/^[\w\/.,:+=@% -]*$/. In Perl $ also matches immediately before a trailing
newline, so a value like "foo\n" is classed plain and inserted bare. The
newline then terminates that shell command line; a second plain tag placed
after it on the template is run as its own command. A user with Events edit
controls event fields that feed tags, so on a filter with an AutoExecute action
this is authenticated command execution.
Anchor with \z (true end of string) instead of $, so any trailing newline fails
the plain test and the value is quoted like other unsafe input.
Addresses GHSA-9v28-c6w5-v8w6.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
zmconfgen.pl wrote zm_create.sql's Config rows without the Private and System
columns, so a database created from it had both at 0 for every option until
zmupdate.pl -f rewrote the table from ConfigData. The web UI relies on Private
to keep a secret out of the browser: skin.js.php exports every option that
isn't Private as a JavaScript constant on every page, the login page
included, and the Options page shows a Private value only to users who can
edit System settings (GHSA-7g3f-93q8-fhwj). So on such a database
ZM_AUTH_HASH_SECRET, which is enough to sign an admin token, was sent to
anyone who loaded the login page. A fresh RPM install is one: the spec runs
zmupdate.pl -f only on upgrade. Debian packages run -f on install.
- zmconfgen.pl writes Private and System like saveConfigToDB() does.
- ZoneMinder::Config::syncConfigFlags() sets each row's Private and System
flags from ConfigData without touching its value, and zmupdate.pl calls it
on every update, so an existing database is repaired the next time it is
updated without needing -f.
- skin.js.php never exports a password option, whatever its flag says.
Tested with zm_create.sql generated from this tree, loaded into a scratch
database: the fresh install has the 3 Private options and 1 System option
ConfigData marks. With every flag zeroed, syncConfigFlags() set those 4 rows
and on a second run changed none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add ZM_PASSWORD_MIN_LENGTH (Options, System; default 8, 0 turns it off).
passwordProblem() refuses a shorter password, so the user page, the API
and both login-page forms apply it alike. Existing passwords keep
working; only a password being set is checked. The length counts
characters, not bytes.
There is no setting for character classes: they push people to
predictable variations (Password1!) and add less than length does.
A database whose Config table doesn't have the option yet (an upgrade
not run with zmupdate.pl -f) gets no minimum. The password inputs carry
minlength so the browser says so before the form is sent; the server
check is the one that counts. The API answers with the English text, as
for the other reasons.
Tests: tests/php/test_default_password.php.
ZM_OPT_USE_AUTH shipped as 0, so a new install accepted every request as
the default user with System=Edit. That includes requests a browser sends
on behalf of any web page a user on the same network opens, so a server
that is never exposed to the internet could still be driven from it.
Default it to 1. zm_create.sql (generated from ConfigData) gives new
installs authentication on. Existing installs keep their value:
zmupdate.pl -f loads Value from the database and writes it back, and only
DefaultValue changes. zm_update-1.39.41.sql also sets DefaultValue to 1
for the row, so that an upgrade run without -f doesn't leave
Value = DefaultValue = 0, which the C++ config loader would skip in favour
of its compiled-in default (on) while the web UI read off.
Update the option help and the user guide (getting started, FAQ, system
options) to say authentication is on for new installs and kept on
upgrade. tests/perl/test_auth_default.pl checks the default, the stored
value and that no update script sets the row's Value; tests/zm_config.cpp
now expects the compiled-in default (opt_use_auth) to be on.
A boolean option set in zm.conf or conf.d wins over the Config table, and a
person editing a file writes yes or off rather than 1 or 0. The C++ daemons
now read those words (ConfigFileValue, 4d7609d36), but PHP and Perl took the
value as written, and a non-empty string is true in both. So
ZM_STRICT_VIDEO_CONFIG=off in conf.d left the option on in the web UI and the
Perl scripts while zmc turned it off.
Read a file value for a boolean option the same way in all three: yes, true
and on are 1; no, false and off are 0; anything else stays as written. Only
booleans are read this way, so a string option such as a title keeps "Yes".
PHP defines the file values as constants before it reads the Config table,
since it needs the database settings to connect, and only the table says
which options are booleans. So a word now waits until the table has been
read, apart from ZM_DB_* settings, whose words database.php reads itself.
Perl reads the type with each row.
Tested on 1.38's zm138 database with a conf.d file setting
ZM_OPT_FAST_DELETE=yes (0 in the table), ZM_STRICT_VIDEO_CONFIG=off (1)
and ZM_WEB_TITLE=Yes. The installed PHP and Perl gave 'yes', 'off' and
'Yes'; these give '1', '0' and 'Yes'. tests/php/test_config_file_booleans.php
checks the rule.
refs #5289
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add JanusEnabled, Go2RTCEnabled, RTSP2WebEnabled and RTSPServer to the
per-monitor telemetry, so we can tell whether Janus and RTSP2Web are still
in use before deciding whether to deprecate them. Nothing in the existing
payload says.
Bring the privacy statement's list of collected monitor fields up to date
too. It listed Function, which no longer exists, and left out Capturing,
Analysing and Recording, and the source path, which is sent with its user
name, password and host replaced. Only en_gb is updated; the other
translations keep their own lists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Event::Close() renamed the event to <prefix><Id>(r) every time. That
was fine while only zmeventtool.pl close and zmaudit called it, by hand
or a day later; with zmwatch closing events left open by a zmc that
died, it would overwrite a name the user set while the event recorded.
Rename only when the name is still the placeholder 'New Event', as
zmc's own close does (~Event: Name = IF(Name='New Event', ...)). The
'Recovered.' note still marks the event.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reolink_HTTP.pm and Uniview.pm said `use JSON`, and
ZoneMinder::General::jsonLoad required JSON. The Debian and Ubuntu
packages depend on libjson-maybexs-perl, not libjson-perl, so on a
packaged install neither control module loads ("Can't locate JSON.pm in
@INC") and jsonLoad, which reads MacVendors.json for the network scan,
logs that error and returns nothing.
Use JSON::MaybeXS as the other control modules (AMLink, Dahua_RPC,
IPSpeaker) and the rest of ZoneMinder::General already do. encode_json
and decode_json behave the same; JSON::MaybeXS picks Cpanel::JSON::XS,
JSON::XS or the core JSON::PP.
tests/perl/test_json_maybexs.pl hides JSON.pm from @INC and loads both
modules and runs jsonLoad.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
start() blocks SIGCHLD (and since #5199 TERM, INT and ABRT) across the fork.
The child unblocks the stop signals, but SIGCHLD stayed blocked through exec,
so every daemon zmdc started ran with it blocked (SigBlk 0x10000 in
/proc/<pid>/status for zmc, zmfilter, zmstats and zmwatch). Nothing depends
on it today, but a daemon that waits for SIGCHLD would never get it. Restore
the pre-fork mask just before exec.
The reaper took the signal from a wait status as $status & 0xfe, dropping its
low bit, so TERM was logged as signal 14, KILL as 8 and SEGV as 10, and the
core dump flag as $status & 0x01. Use 0x7f and 0x80.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: give a monitor's new zmc a grace period in zmwatch before restarting it
zmwatch's startup grace counted from zmwatch's own start, so a zmc started
later (a monitor added or saved through the web UI or API, a zmdc restart)
had none. When a zmwatch pass landed in the seconds between that zmc being
forked and it mapping its shared memory, zmwatch logged "shared data not
valid" and asked zmdc to restart it, replacing a zmc that was about to come
up.
Count ZM_WATCH_MAX_DELAY per monitor from the pass that first found its
shared data missing or invalid, and forget it once the data is valid again
or after the restart. The zmwatch-start grace and the heartbeat checks are
unchanged, so a zmc that hangs after mapping its memory is restarted as
before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: don't lose a stop that zmdc sends to a daemon before it has exec'd
zmdc forks each daemon and, in the child, connects to the database, closes
every descriptor up to _SC_OPEN_MAX and only then resets TERM, INT and ABRT
to their defaults before exec. Until then the child runs zmdc's own
shutdown handler, which only sets $zm_terminate in the child, so a TERM sent
in that window is lost. The daemon then starts as if nothing happened, zmdc
waits KILL_DELAY (30 s) and KILLs it, and an event that zmc opened in the
meantime is left with no EndDateTime.
zmwatch's restart of a zmc that hasn't mapped its shared memory yet lands
in this window. Closing descriptors one by one takes about 1.7 s with a
1048576 open-file limit (Docker's default), which makes the window wide.
Block TERM, INT and ABRT across the fork along with SIGCHLD, and in the
child reset them to their defaults and unblock them first thing, so a stop
sent before exec ends the child and the reaper restarts or forgets it as
asked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf: close only open descriptors in zmdc's child before exec
The child closed every descriptor number from 3 up to _SC_OPEN_MAX one by
one. With a 1048576 open-file limit (Docker's default) that is about 1.7 s
of close() calls before each daemon starts, and it is the window in which
zmwatch can find a just-started zmc without shared memory.
Close the descriptors /proc/self/fd lists instead, and keep the old loop
where /proc isn't available.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix: validate alarm packets in zmalarm-server.py
The alarm server accepted any packet from any host and mapped it to a
monitor by the Address field inside the packet, so a host on the network
could raise an alarm or a zmtrigger event on any configured camera.
Every malformed packet (short header, non-JSON body, unknown address)
raised an uncaught exception that ended the process, and a client that
connected without sending blocked the accept loop indefinitely.
- only accept an alarm whose Address matches the address it came from
and a monitor that uses that address
- read the header and body with a 5 s timeout, cap the body at 64 KiB,
log and drop bad packets instead of exiting
- resolve camera hostnames in monitor Paths and skip monitors without a
camera address instead of failing at startup
- pass the alarm description to the zmtrigger thread instead of reading
a global, and talk to zmtrigger over a plain socket (telnetlib was
removed in Python 3.13)
- honour the bare port number zmpkg.pl passes from ZM_OPT_ALS_PORT and
add --bind= to choose the listening address (default unchanged)
- move the pyzm imports into main() so the parser can be tested; add
tests/python/test_zmalarm_server.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: accept alarms from cameras behind NAT in zmalarm-server.py
The source check required the packet's Address field to equal the address
the packet came from. A camera behind NAT reports its LAN address there
while its packets arrive from the NAT address, so its alarms were dropped.
The Address field is no credential, so the monitor is now chosen by the
sender: an alarm is accepted only from an address that belongs to a
monitor, the camera address in its Path or one listed for it under
[alarmserver] in secrets.ini, and it goes to one of that address's
monitors. The Address field only picks among several monitors on one
address, for cameras behind a shared NAT address. A host that belongs to no
monitor still cannot raise an alarm, and a camera cannot raise one on
another camera's monitor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: cancel the forced alarms zmalarm-server.py raised when it stops
An alarm packet armed the monitor, slept 60 s in a thread and disarmed it.
When zmdc stopped or restarted the server inside that window (SIGTERM, which
Python's default handler ends at once) the disarm never ran and the monitor
stayed in forced alarm, recording, until someone cancelled it by hand.
Keep the forced alarms the server raised in one place, one per monitor: a
new alarm for a monitor in forced alarm arms it again, as every packet did,
and moves its end instead of starting a second timer that would cut it
short. On the way out, SIGTERM included, the server cancels every alarm
still running and exits 0, so zmdc no longer logs the stop as abnormal. An
arm that fails is logged instead of ending the server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
An event open when its zmc is killed (lost stop, OOM, crash) keeps
EndDateTime NULL. Nothing closes it on a default install: zmaudit only
does with ZM_RUN_AUDIT on and after ZM_AUDIT_MIN_AGE, and the next zmc
does not look at earlier events.
A running zmc closes its event before it reconnects and sets
startup_time in shared memory after it connects, so an open event of
its monitor that started before that time belongs to a process that is
gone. Once per zmc start, when its heartbeat is fresh, zmwatch closes
those events through Event::Close(). Only this server's monitors are
checked, and re-analysis events (Cause 'Re-analysis', created by zma
with their original times) are left alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Event::Close() (zmeventtool.pl close) took the end of an event only from
its Frames rows and refused events that had none. zmc writes Frames rows
in batches and updates the event row's Length, Frames and scores with
each batch, so an event whose zmc was killed in its first seconds has no
Frames rows, and either side can be ahead of the other.
Take Length, Frames, AlarmFrames, TotScore and MaxScore as the larger of
the event row and the Frames rows; EndDateTime follows the Length that
was used. The name and Notes are written as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The skin and css, the language and locale, the date, time and datetime
formats, the default bandwidth and the snapshots feature switch all
decide how the interface looks or what it offers, which is what that tab
is for, but they sat under System among the shutdown command, the shm
key and the audit intervals. System drops from 42 entries to 33 and
Display goes from 32 to 41.
The snapshots switch is read only by the skin: it gates a button in
montage and montage review, a menu item, and the snapshot views and
their permission checkboxes. Nothing in the daemons or the perl scripts
looks at it.
The category key is still 'web'. That is the tab now labelled Display;
the tab whose key is 'display' holds the per-session cookie settings and
stores no configuration at all, so a stored default does not belong
there. The hint setup for the moved entries -- the installed skin list,
the css list for the current skin, the bandwidth options, and the
language and locale lists -- moves to the same branch.
ZM_TIMEZONE stays under System. It reads like a presentation setting,
but Filter.pm uses it for date arithmetic when evaluating filters, so it
changes behaviour and not just what is displayed.
A migration, 1.39.40, is needed as well as the ConfigData change, because a
version upgrade does not rewrite Config: zmupdate only reloads it from
ConfigData under --freshen, which is mutually exclusive with an upgrade
run. Fresh installs take the new category from the generated
zm_create.sql.
Verified both paths converge on 33 system and 41 display entries with
identical membership: a fresh install of this branch, and a copy of a
real pre-change Config table with the migration applied. On the running
install the bandwidth, language and locale entries still render as their
pickers on the new tab, css as its list of choices, and the timezone
picker is still on System with its full list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
Two problems, both in exportsql.
The mysqldump command ended with a hardcoded database name, so --export
always read from a database called zm whatever ZM_DB_NAME said. It now
uses the configured name.
The primary keys were then blanked with a regex that replaced everything
between VALUES ( and the first quoted string:
s/VALUES \((.*?),'/VALUES \(NULL,'/ig
That holds only if the id is immediately followed by a quoted value. A
MonitorPresets row starts id,ModelId and both are unquoted, so the
pattern swallowed the pair into a single NULL and left the row one value
short of its columns. On the shipped data that corrupted all 75 preset
rows of a 126 row export; the 51 control rows came through intact, which
is presumably why it went unnoticed.
Replaced with the same parse the import now uses: split the values on
top level commas, find the id by column name, and blank just that one,
re-emitting the row in the form it arrived in. A row whose value count
does not match its table is left alone with a warning rather than
rewritten on a guess.
Verified against a real dump of the seed data: 126 rows, every one with
its id blanked, and not one value-count change -- against 75 corrupted
by the old pattern. Round-tripped the export back through --import into
an empty database: 51 controls and 74 presets, no broken names, no
unresolved control references, and every column identical to the source
apart from the one preset row whose name is a duplicate of another,
which the import has always collapsed because it keys on Name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
The import read zm_create.sql line by line and pulled the Controls and
MonitorPresets rows out with a regex. Two things were wrong with that.
It found no controls at all. The seed rows moved to db/controls.sql,
which zm_create.sql only pulls in with a `source` directive, and the
loop did not follow it, so --import silently stopped importing ptz
controls entirely.
The regex also mis-parsed the rows it did find. It assumed every INSERT
was positional with the id first and the name straight after, then
replaced everything before the first ,' with NULL. Ten of the shipped
controls are written with an explicit column list, where the first value
is the name, so it overwrote the name with NULL and keyed the row on the
protocol instead:
IN : INSERT INTO `Controls` (`Name`,`Protocol`,`CanReset`)
VALUES ('Amcrest ASH42-B RPC','Dahua_RPC',1);
OUT: INSERT INTO `Controls` (`Name`,`Protocol`,`CanReset`)
VALUES (NULL,'Dahua_RPC',1);
It also happened to work for MonitorPresets only because the two columns
before the name are numeric and unquoted.
Replaces it with a parser that reads the column list when there is one
and asks the database for the column order when there is not, so nothing
depends on the name sitting at a fixed offset -- it is the second column
of Controls but the third of MonitorPresets. Values are split on top
level commas only, leaving quoted strings and parenthesised subqueries
intact, which matters now that a name may contain a comma and a preset
resolves its control with a SELECT. The id is blanked wherever it
appears so the row still gets a fresh one, and a row whose value count
does not match the table is skipped with a warning rather than inserted
crookedly.
`source` is followed, relative to the including file, so the controls
come back. A file that cannot be read warns instead of dying, since
zm_create.sql sources several that are optional.
Verified against the real seed data: 51 controls parsed where it
previously found none, 74 presets, no row imported with a null or empty
name, the ten explicit-column controls landing with their name and
protocol intact, every preset's control subquery resolving, and names
containing commas preserved. Exercised the splitter directly on a
positional row, an explicit-column row, a row with both a comma in the
name and a subquery, and a doubled-quote escape. Round-tripped a
mysqldump export back through the import. Generated script passes
perl -Tc.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
Controls.Type looks like the monitor type a control applies to, and has
been kept in step with the Monitors enum since 1.27, but nothing has
ever read it. The control dropdown on the monitor page lists every row
by Name, no query filters on it, and no code compares it to the
monitor's own Type. It was a field the edit form asked for and the
control list displayed back.
The column appears to be MonitorPresets.Type copied by analogy, where
the idea is real: that one decides what a preset creates. That also
explains why every migration altering one altered the other.
Removes the column from the schema, adds the 1.39.39 migration, the edit form row
and its type list, the control list column, the defaults in Control.php
and Control.pm, and the value from all 53 shipped rows in controls.sql.
This supersedes the 1.39.38 retyping of three libvlc controls to Ffmpeg:
the column they were being retyped into is gone.
zmcamtool needs no change -- its import regex rewrites the Id and
captures the Name, so it is not positional past that, and its export is
a mysqldump that follows the live schema.
Dropping Type left the two shipped Pelco rows byte-identical to their
twins, which existed only as a Local and an Ffmpeg copy of the same
control, so the redundant copies are removed: 53 shipped rows become
51, with both Pelco-D and Pelco-P still present once.
Existing installs are deliberately left with their duplicates. The same
pair collapses there too, but deleting rows a monitor's ControlId may
point at, to tidy a repeated name in a dropdown, is not a trade a
migration should make silently.
Verified by loading the old and rewritten controls.sql into scratch
databases and diffing every remaining column across all 53 rows: no
value shifted, which is the failure mode when a positional INSERT loses
a field. The file mixes positional and explicit-column INSERTs, so both
forms were handled. Fresh-install and migrated Controls schemas compare
equal, the shipped data loads into both, and Monitors and MonitorPresets
keep their Type. Build clean, 147 test cases pass, php -l and perl -c
clean on the edited files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
Follows the daemons: no monitor can be type Libvlc any more, so the ui
should not offer it or carry text describing it.
Drops the source-type option and its deprecation notice, the Libvlc arm
of the Source Path and console source-column branches, the entry in the
control-capability type list, and the value from the api's Type
validation list. The OPTIONS_LIBVLC help block goes from all 25 language
files: monitor.php builds that key as OPTIONS_ plus the monitor type, so
it became unreachable the moment the type did. The Libvlc source-type
label goes from the 22 files that carried it.
SourceTypeDeprecated and Deprecated lose their last caller and are
removed. RemoteRtspDeprecated and Removed stay -- the rtsp protocol
option is still shown to monitors already set to it.
The WEB_FILTER_SOURCE help listed the affected source types; corrected
in ConfigData.pm.in and in the one translation that had localised it.
All 25 language files and the four edited php files parse clean under
php -l, and the config module parses under perl -c. No javascript
changed, so eslint has nothing to say here -- it cannot run in this
worktree anyway, which has no node_modules.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
RTSPDescribe chose whether to take the media url from the DESCRIBE
response rather than the configured path. That was a quirk of
ZoneMinder's own rtsp implementation; ffmpeg decides it itself. Removing
that client left the setting with nothing behind it -- the monitor form
still drew the checkbox and the save path still wrote the column, but no
code read it, so the only thing it could do was mislead whoever ticked
it.
Drops the form row and the Remote-only block that existed to hold it,
the javascript that showed and hid it as the protocol changed, the field
in the php and perl monitor models, the default in the save action, the
column from the schema, and the label and help text from all the
language files carrying them.
The 1.39.37 migration drops the column behind an information_schema
guard scoped to DATABASE(), so the file stays re-runnable.
Verified by building a pre-upgrade database from upstream/master's own
schema and applying the migration: the column goes, and Monitors matches a fresh install of this
branch column for column. Migration applied three times with no error.
php -l clean on the four edited files and all 28 language files, perl -c
clean, build clean, 154 test cases pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
FOSCAMR2C presetGoto and presetSet interpolated the preset into
"SELECT Label FROM ControlPresets WHERE Preset = $preset" and sent the selected
Label to the camera as name=. For presetGoto the preset came straight from the
web request, so any user who could see the monitor could run a SELECT against
the ZoneMinder database and have the value (the auth hash secret, password
hashes) delivered to the camera link, or use it as a boolean oracle.
- FOSCAMR2C: bind the preset in every statement, refuse a preset that is not a
number, and look ControlPresets up by MonitorId as Dahua and PSIA do.
- buildControlCommand(): refuse a non-numeric presetGoto preset, and a control
name that is not a plain method name. zmcontrol calls the name as a method and
the options are split back out of the command string on spaces.
- zmcontrol.pl: refuse a command that is not a plain method name, so a qualified
Package::sub name can't call a sub outside the control module.
- canView()/canEdit() with a monitor id returned only the monitor check and
ignored the area, so canView('Control', $mid) passed for anyone who could see
the monitor. Require the area permission as well; Monitors stays decided by
visibleMonitor()/editableMonitor().
tests/php/test_control_command.php covers buildControlCommand() and the
monitor-scoped canView()/canEdit(); tests/perl/test_foscamr2c_preset_sql.pl
checks the statements FOSCAMR2C prepares and what it sends to the camera.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zmupdate.pl applied every zm_update-*.sql at or above the database's
version, with no upper bound. Migration files from a newer release left
in the update directory, after a downgrade or with a newer build's files
installed over an older one, were applied to a schema they were not
written for. Installing a 1.38 build over a 1.39 one ran the 1.39.0 to
1.39.5 migrations against the 1.38 database before one failed.
Skip, and say so, any migration newer than ZM_VERSION.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
go2rtc serves its API under /api, and its websocket at /api/ws. zmc
appends /streams to ZM_GO2RTC_PATH and the browser appends /ws, so the
setting only worked if it ended in /api. The natural value to enter is
the server address, http://host:1984, and with that zmc reached
/streams, which is go2rtc's RTSPtoWeb compatibility endpoint, so no
stream was ever registered, and the browser's /ws was a 404. Where
another player was available the failure went unnoticed.
Normalise the setting where URLs are built from it: a trailing slash is
dropped and /api is added unless the path already ends in it, which also
covers go2rtc behind a reverse proxy at a sub-path.
- Go2RTCApiEndpoint() in zm_utils, used by zmc's Go2RTCManager.
- go2rtcApiUrl() in skin.js, used by MonitorStream and the console
thumbnail stream. It assigns the path once, since stripping the slash
from a bare host's "/" and appending gave "//api", as the old
pathname += '/ws' gave "//ws".
- The option's help says the suffix is optional.
Catch2 and node tests cover bare hosts, trailing slashes, an existing
/api, a proxy sub-path and credentials in the URL.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The patch loop writes ZM_DYN_DB_VERSION after each file so that a run
that dies partway leaves a record of how far it got. It wrote $version,
the version the database started at, which the loop never changes, so
the write was a no-op: a failed upgrade left the original version in
place and the rerun started again from the beginning, re-running every
patch that had already applied.
Write $v, the version of the patch that was just applied. A run that
completes still ends up at ZM_VERSION from the cascade write below.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLYswrguKc6bBYL8JZbpmZ
* 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>
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>
GenerateVideo used DefaultVideo as the ffmpeg input relative to the event
directory and built the output file from Name with only whitespace
replaced, so an Events=Edit user could make zmvideo read or write any
path the account can reach. zmfilter's generateImage passed
event_path/DefaultVideo to ffmpeg -i, and the %EV% email tag attached
event_path/DefaultVideo, which could mail out any readable file.
Strip everything up to the last path separator from DefaultVideo in all
three places, and reduce Name to [-A-Za-z0-9_.] with no leading dot
before using it as the video filename.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 5904023f13448e557dc205edb9440972e4a6482d)
executeCommand substituted %TAG% placeholders in AutoExecuteCmd with raw
event and monitor fields and ran the result with qx(), so the shell parsed
those values. Several are writable by non-admin users: event Name, Cause
and Notes (Events=Edit via ajax/event.php and the API), event tags
(Events=Edit), monitor Name (Monitors=Edit), and Cause/Notes are also set
by zmtrigger clients on TCP 6802. An event named $(cmd) therefore ran cmd
as the web/zm user whenever an admin's AutoExecute command used %EN%,
%EC%, %ED%, %ETAGS%, %MN% and similar tags.
Each tag is now substituted on its own. Values made only of word
characters, spaces and /.,:+=@%- are inserted as before, so commands using
%EID%, %MID%, %EPATH% and similar keep working unchanged. Any other value
is exported as an environment variable ZM_TAG_n and referenced in a form
the shell expands without re-parsing, chosen from the quoting context the
admin placed the tag in: "${ZM_TAG_n}" when bare, ${ZM_TAG_n} inside
double quotes and '"${ZM_TAG_n}"' inside single quotes. Such a value
now always reaches the command literally; a bare one is no longer split
into several words.
substituteTags itself is unchanged, so email and message bodies behave
as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b9891a4a443f1f5e0d8faa7026518a7463ec9440)
With ZM_AUTH_HASH_IPS on, the client address bound into the auth hash was
taken from the left-most X-Forwarded-For value whenever the header was
present, both when PHP generated the hash (getRemoteAddr()) and when PHP or
zms validated it (getAuthUser(), zmLoadAuthUser()). The header is client
controlled, so anyone holding a leaked hash could replay it from anywhere by
sending the address it was bound to.
Add ZM_AUTH_TRUSTED_PROXIES, a list of exact reverse proxy addresses.
X-Forwarded-For is now used only when REMOTE_ADDR is one of them, and is read
from the right, skipping hops that are themselves listed proxies, so values a
client prepends are never chosen. With the option empty, the default, the
header is ignored and REMOTE_ADDR is used.
PHP (web/includes/Network.php getRemoteAddr()) and C++ (ClientAddress() in
zm_utils, used by zmLoadAuthUser()) implement the same rule so generation and
validation continue to agree. Every PHP caller already routes through
getRemoteAddr(), so session.php and auth.php need no change.
Reverse proxy users who enable ZM_AUTH_HASH_IPS must list their proxy in the
new option; until they do, hashes bind to the proxy address, which still
validates but no longer distinguishes clients. This is the behaviour change
that the #4921 work avoided by trusting the header.
The option is added through ConfigData only, like other recent options;
zmupdate.pl --freshen inserts it, zms falls back to the compiled-in default and
PHP treats an undefined constant as empty, so no schema migration is needed.
Tests: ClientAddress Catch2 case; tests/php/test_remote_addr.php updated for
the trusted-proxy rule.
refs GHSA-72rf-54rm-798c
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit acea5889dec0826f595cb736147a5fdc9c94a2a9)
GHSA-pxq8-5c8j-xf3r reports the event Name reaching a shell through
Event::GenerateVideo. That sink is already closed by "fix: run the event
video encoder without a shell" (GHSA-pfph-4j9j-7cv7), which runs ffmpeg as a
list. Reviewing the rest of the Perl video path turned up one more place an
event field reaches a shell: zmfilter's generateImage, used to attach frames
to filter emails and messages when no capture jpeg exists, built
ffmpeg -nostdin -ss <Delta> -i '<event path>/<DefaultVideo>' ...
and ran it with qx(). DefaultVideo is settable by an operator with
Events=Edit, so a single quote in it ends the quoted argument. The -r check
before it means the named file has to exist, but GenerateVideo itself will
create one whose name is the event Name, so the two fields together are
enough. Driving generateImage directly with DefaultVideo set to
"a';touch${IFS}<marker>;'.mp4" (and that file present) created the marker
before this change.
ffmpeg is now run through a list-form piped open, so there is no shell and
the path is one argument whatever it holds. stdout is still collected for the
debug log as qx() did; if ffmpeg cannot be started, status is reported as
127, as the shell did for a missing command. With the change the same input
passes the literal path to ffmpeg and creates no marker.
See GHSA-pxq8-5c8j-xf3r.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 0f700d30e8b5e67705243029b57005236ccbc3ff)
zmfilter.pl rebuilds SQL from the JSON the web tier stores verbatim in
Filters.Query_json, so a saved filter's attribute, operator and value all
arrive at the statement exactly as they were written. None of the three were
checked here, while web/includes/FilterTerm.php checks all three: values were
wrapped in quotes by hand, which a value containing one closes; the final
attribute branch concatenated 'E.'.$attr; and the generic operator branch
emitted the operator as written.
Any account that can save a filter could therefore have the daemon run SQL of
their choosing with its own credentials, against a table set that includes
Users. The daemon also deletes and moves events, so this is not only a read.
Confirmed against the module before the change, building SQL from stored terms
and checking whether the payload landed outside a string literal:
val = "x' OR 1=1 -- " -> outside, injected
attr = "Id=1 OR 1=1 -- " -> outside, injected
op = "= 1 OR 1=1 -- " -> outside, injected
Attributes now have to match what the PHP accepts, operators have to be one of
the sixteen the UI can produce, and a filter carrying anything else is refused
rather than run. Values go through DBI's quote().
Numbers are left unquoted so the statements the existing filters produce are
unchanged: a bare number cannot carry SQL, and quoting it would have altered
every numeric comparison in the file for no gain. Verified by generating the
SQL for seven representative filters before and after -- purge-when-full,
delete-after-a-day, LIKE on a name, IN on monitors, a monitor name, a score
range and an IS NULL -- and diffing: identical apart from the timestamp a
relative date resolves to.
After the change the three payloads above are contained in a literal, or the
filter is refused.
See GHSA-p8h3-4x5c-cv7p.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBHBB95RBX7D9p8ge2WDZb
(cherry picked from commit bc11642fea844bc86bac0080da42e4514d5a4d76)