Skip to content

fix: validate alarm packets in zmalarm-server.py - #5211

Merged
connortechnology merged 3 commits into
ZoneMinder:masterfrom
nabbi:fix/zmalarm-server-validate
Oct 12, 2026
Merged

connortechnology merged 3 commits into
ZoneMinder:masterfrom
nabbi:fix/zmalarm-server-validate

Conversation

@nabbi

@nabbi nabbi commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Automated PR, tested in a zm-holodeck sandbox. An AI agent (Claude Opus 5.5) reproduced the bug on a disposable ZoneMinder built from upstream master, wrote this fix and ran the checks below on the same lab. Minimal human review before opening: please review it as you would any contributor's PR.

TL;DR

  • Fixes: scripts/zmalarm-server.py trusts the Address field inside the alarm packet to pick the monitor, so any host that can reach the port can force a 60 s alarm on any camera. It also exits on any malformed packet, stalls on an idle client, ignores the port zmpkg.pl passes, and leaves a forced alarm on if zmdc stops it during the 60 s window.
  • Change: the sender's address picks the monitor and must belong to one (the camera address in its Path, or an address listed under [alarmserver] in secrets.ini), so cameras behind NAT keep working and other hosts are ignored. Reads time out and are size-capped, bad packets are dropped, and forced alarms are tracked per monitor and cancelled on SIGTERM. 3 commits, 2 files, +564/-122, with a Python unit test (25 cases).
  • Risk: low reach: CMake and the packages don't install this script and ZM_OPT_USE_ALARMSERVER is off by default, so only hand-installed copies change. Nothing in the web UI or the API clients (pyzmNg, zm-py/ha-zoneminder, zmNinjaNg, zmeventnotificationNg) reads or starts it; its only caller is zmpkg.pl. Behaviour changes are in the table below.
  • Tested: reproduced before; the unit test passes on this branch (25/25); the live probes pass with the script installed by hand.
Details: reproduction, behaviour changes, checks

Before

Upstream master, script installed by hand, ZM_OPT_USE_ALARMSERVER=1:

  • Forged packet: from one camera (172.16.12.5) claiming another's Address (172.16.12.6): zmc_m3 Gone into alarm state ... Cause:Forced Web, an event on monitor 3, 60 s.
  • Malformed packets (short header, non-JSON body, unknown Address): each one ends the process; zmdc restarts it with back-off.
  • An idle connection held the server for 38 s; a real camera alarm sent meanwhile was lost.
  • Listens on 0.0.0.0:15002 regardless of ZM_OPT_ALS_PORT.
  • Stopping the server 57 s into a forced alarm left the monitor in forced alarm, with the event open, until cancelled by hand.
  • On a stock build (without the hand install) zmdc logs Can't exec: No such file or directory and restarts it in a loop. Not changed here; installing or removing the script is a separate question.

Behaviour changes

Change Who sees it Intended / collateral Alternatives looked into Chosen, why Tested by
An alarm is accepted only from an address that belongs to a monitor (its Path's camera address, or listed under [alarmserver] in secrets.ini) any host that reached the port intended a shared secret in the packet (the camera protocol has no field for one); bind to one interface (kept as --bind=) sender address: the one thing a TCP sender can't fake unit tests; probe forged: 0 events
The sender's address picks the monitor; the packet's Address only chooses among several monitors on one address cameras reporting an Address other than the one they send from (NAT) intended require Address = sender (drops every camera behind NAT); an opt-out flag (reopens forged alarms, and zmpkg can't pass it) sender picks, [alarmserver] for what it can't decide unit tests (NAT, shared NAT, a camera claiming another); probe nat: 1 event
A camera claiming another camera's Address alarms its own monitor misconfigured or compromised cameras collateral, flagged reject the mismatch (that is exactly the NAT case) a camera can only alarm itself probe other
Several cameras behind one NAT address need monitor_<id> = <LAN address> lines, or their alarms go to the last monitor of that address sites with several cameras behind one NAT collateral, flagged refuse ambiguous alarms last monitor, as the unpatched script did unit test; probe config
Stopping or restarting the server during the 60 s window cancels the forced alarms it raised --alarm=y users intended a timed alarm (neither zmu nor the API has a duration) SIGTERM handler + cancel on exit; a SIGKILL or crash still leaves it on unit tests; probe stop: alarm 2 → 0 within 5 s, event closed
A second packet in the window re-arms and moves the end, one timer per monitor (was: a second thread whose disarm cut the first alarm short) --alarm=y users intended skip packets while active (a web cancel wouldn't come back) re-arm + extend unit test; probe extend
Clean stop exits 0 (zmdc: exited normally, was exited, signal 14) log readers intended probe stop
Reads time out after 5 s, bodies capped at 64 KiB, malformed packets logged and dropped; hostnames in Paths resolved; ZM_OPT_ALS_PORT honoured; --bind= users of the script intended unit tests, probes

Release note (for anyone who installed the script by hand): "zmalarm-server.py now accepts an alarm only from an address that belongs to a monitor: the camera address in its Path, or an address listed for it in /etc/zm/secrets.ini under [alarmserver] as monitor_<id> = <address> .... Cameras behind NAT work when the monitor's Path holds the NAT address; several cameras behind one NAT address need their LAN addresses listed. Stopping the server now ends the forced alarms it started. ZM_OPT_ALS_PORT is now honoured: if you changed it, point the cameras at that port."

Checks

Check Result
tests/python/test_zmalarm_server.py (plain python3) 25/25 on this branch; fails on the unpatched script
Live probes (forged, nat, other, config, stop, extend), script installed by hand and started through zmdc.pl as zmpkg does pass as listed above; run on a lab branch with these three commits plus one more that only changes the script's zmu -s/-x checks (not in this PR)
holodeck checks (same lab branch) unit tests, script tests and smoke pass

Not run: Python 3.13 (the script needs telnetlib and the pyzm 0.x API, so 3.12 or older; unchanged here). The same three commits apply cleanly to release-1.38, where the file is identical.

🤖 Generated with Claude Code

nabbi and others added 3 commits October 11, 2026 10:45
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>
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>
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>
@connortechnology
connortechnology merged commit 51b04e9 into ZoneMinder:master Oct 12, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants