151 Commits
Author SHA1 Message Date
Nic BoetandClaude Opus 5.5 f681c23825 fix: log a fingerprint of the auth hash in getAuthUser()
getAuthUser() logged the presented auth hash at debug level on every
check and at Info level whenever a check failed. Info reaches the Logs
table at the default database log level, which System=View users read.
A hash refused here can still be valid: bound to another client address
(ZM_AUTH_HASH_IPS) or meant for another server. Log its fingerprint,
the same tag zms and zm_rtsp_server log for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:27:39 -05:00
Nic BoetandClaude Opus 5.5 06b7a43b2b fix: stop logging the encoded password and bcrypt hash at login
For a user whose hash zmupdate.pl overlaid with bcrypt ("-ZM-" prefix),
validateUser() logged the MySQL-encoded form of the typed password next
to the stored bcrypt hash at debug level, on every login attempt, right
or wrong. The MySQL-encoded form is SHA1(SHA1(password)), unsalted, so
it falls to an offline dictionary attack. The line above it already
says which comparison runs.

Adds tests/php/test_auth_log_redaction.php, which records every log line
validateUser() writes for each stored hash type and searches them for
the password, its encoded form and the stored hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:27:22 -05:00
Isaac Connor 0f7bb088dc Merge commit from fork
fix(api): check the group, snapshot and tag links an edit will save (GHSA-6h3c-f3p7-pq4v)
2026-10-31 19:06:37 -04:00
Isaac Connor ac1e167e6e Merge pull request #5382 from connortechnology/fix/ghsa-2pgc-session-fixation
fix: reject client-chosen session ids and regenerate on auth= / stateful API login (GHSA-2pgc)
2026-10-31 09:34:50 -04:00
Nic Boet 2985fb9154 fix: new session id when an auth= or stateful API login takes a session
An auth= login (ZM_AUTH_HASH_LOGINS) and the API's stateful login
(stateful=1 with user/pass, legacy API auth) wrote the user into the
session the client arrived with, keeping its id. With an id planted in
the client beforehand, the attacker's cookie became the victim's
session. The login form already issues a new id.

Both now call zm_session_regenerate_id_login() first: auth= when a
session the client carries a cookie for changes to another user, the
API whenever credentials log in. The API's cookie path no longer logs
the whole session.

The API's stateful login also drops $_SESSION['password'] =
$_REQUEST['password']. This is not a behaviour change: API logins send
pass=, not password=, so under ZM_AUTH_RELAY=plain the line stored
nothing for them. A stateful request that logs in with username= and
password= goes through zm_authenticate_request()'s form-login path,
which stores the password itself, as before. zmu gets its credentials
from zmuCredentials() (the zmu token change, which this one follows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit bb3bd92b92)
2026-10-31 09:04:37 -04:00
Nic Boet 01ac955ad9 refactor: move zmuCredentials() above zm_authenticate_request()'s comment
zmuCredentials() was inserted between zm_authenticate_request() and the
comment that documents it. Move the function and its own comment above
that block. No code change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 0779162df844ea6bfd45fd3b88c0e30ad922b7ef)
2026-10-30 19:30:28 -04:00
Nic Boet 46ccb7529f fix: without a token secret, zmu still gets an API request's own password
When ZM_AUTH_HASH_SECRET is unusable zmu refuses tokens, so
zmuCredentials() falls back to what zmu got before. With
ZM_AUTH_RELAY=plain the API's alarm action used to pass the password
from the request (pass= or password=); the fallback read only the
session, which an API call without a stateful login doesn't have, so
zmu refused the call (rc 255). zmuCredentials() takes the caller's
password first again. With a usable secret it is never used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 5ca19c2703ea9211fe040991305ce9b0a873faff)
2026-10-30 19:30:28 -04:00
Nic Boet 35e36e4dda fix: give zmu a short-lived token instead of the password or auth hash
getZmuCommand() and the API's alarm action put the user's credentials on
zmu's command line, where every local user can read them (ps,
/proc/<pid>/cmdline): an auth hash with the default hashed relay, the
plaintext password with ZM_AUTH_RELAY=plain. The API also wrote the
whole command to the debug log. With the default ZM_AUTH_HASH_IPS=0 that
hash is accepted as an auth= login from any address for ZM_AUTH_HASH_TTL
hours.

zmuCredentials() builds them in one place: with a usable
ZM_AUTH_HASH_SECRET, an access token for the user that expires after a
minute (zmu -T, accepted with every relay). Without the secret zmu
refuses tokens, so it gets what it did before. The API logs the command
without the credentials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 32bc2b3fee87b752590ba6f210403afd9222d9ff)
2026-10-30 19:30:27 -04:00
Nic Boet b70408751b fix: refuse edits to a monitor the user can't see
editableMonitor() only looked for an Edit grant. A None deny on one of the
user's groups, or on a role group, was skipped and the global Monitors=Edit
permission decided, so a user scoped out of a monitor could still change it
through the API (PUT monitors/<id>.json), the monitor form, zones and alarms
while visibleMonitor() hid it from them. Check visibility first.
2026-10-22 21:05:33 -05:00
Isaac Connor b7405c58c5 Merge pull request #5288 from nabbi/hard/auth-default
Ask at first run whether to require a login, and refuse the password admin
2026-10-16 19:03:06 -04:00
Nic Boet b631d2efc9 feat: add a minimum length for new passwords refs #5287
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.
2026-10-16 10:13:53 -05:00
Nic Boet fd93780425 fix: refuse the password admin wherever a password is set refs #5287
The login page's change form and the first-run page refused admin as a
new password, but the user page (Options, Users, and a user's own page
with ZM_USER_SELF_EDIT) and the API (users/add, users/edit) still stored
it. Every login then refused that account.

Check a new password in one place, passwordProblem() in auth.php, and
call it from all four:

- includes/actions/user.php, for an admin's edit and a self edit: the
  save stops with the reason shown on the page. A Password sent as an
  array is refused too (it reached password_hash() before).
- api/app/Model/User.php: an "acceptable" validation rule, so add and
  edit answer with the reason as the validation message. The API loads
  no language file, so passwordProblemText() gives it the English.
- newPasswordProblem() (both login-page forms) checks the confirmation,
  then passwordProblem().

Tests: tests/php/test_default_password.php covers passwordProblem() and
passwordProblemText().
2026-10-16 10:12:53 -05:00
Nic Boet ebbe1e59e2 feat: ask on the first page whether to require a login refs #5287
A new install starts with authentication on and the admin password admin,
which is refused everywhere. Asking for that password just to replace it is
an extra step, and some installs don't want a login at all. Ask instead.

While the first run is pending (authentication on, the privacy page not
answered yet, builtin auth, and admin still has the password admin), the
login page shows "Set up ZoneMinder" instead of the login form:

- Require a login: choose the admin password; the account is then logged
  in and the privacy page follows, as before.
- Don't require a login: ZM_OPT_USE_AUTH is set to 0 in the database.
  Not offered when ZM_OPT_USE_AUTH is set in zm.conf or conf.d: the owner
  of the box has decided then, and only the password is asked for.

Until one is chosen the web interface, API and streams can't be used: the
password admin is still refused. Whoever opens the page first makes the
choice; before this, whoever typed admin/admin did. The form carries a
nonce kept in the session, like the default-password form, and is exempt
from csrf-magic as action=login is (the login regenerates the session id).
Both choices are audited. Upgrades are not affected: ZM_SHOW_PRIVACY is 0
once the privacy page was answered, and zmupdate.pl doesn't reset it.

Docs: a First Run section in the getting-started guide, and the FAQ and
system options updated to match.

Tests: tests/php/test_first_run.php.
2026-10-16 10:10:25 -05:00
Nic Boet cb4c5f5314 feat: ask for a new password when an account logs in with the default one refs #5287
db/zm_create.sql seeds the admin account with the password the user
guide documents (admin), so with authentication on a new install is
still open to anyone who reads the guide.

Refuse that password as a credential and have the web login replace it:

- validateUser() returns false with "This account uses the password
  admin, which ZoneMinder does not accept. Log in to the web interface
  to set a new one." when the password is right but is admin. The API (login.json and
  user=/pass= on any request) answers 401 with that message; the
  user=/pass= web parameters are refused the same way.
- On the login page (action=login) the same login shows a form that
  asks for the current password again and a new one, with a nonce kept
  in the session so another site can't submit it for the user. The new
  password must be set, confirmed and not the default. The account is
  logged in only once it has been replaced; the change is audited.
  That POST skips reCAPTCHA when it carries the form's nonce: the form
  follows a login that passed it, and doesn't render the widget.
  With ZM_AUTH_RELAY=plain the session keeps the new password.
- zmLoadUser() refuses the default password, so zms, zmu and the RTSP
  server don't accept it in user/password parameters either.

The check is on the password typed, so it needs no schema change, covers
existing installs whose admin still has the default password, and
follows the account if it is renamed. A password set to the default
again later is caught the same way. Sessions, tokens and auth hashes
issued before the upgrade keep working until they expire; with
ZM_AUTH_TYPE=remote the web server's login is not affected.

The FAQ now says to force authentication on in conf.d: since 4d7609d36
the daemons apply zm.conf and conf.d values over the database too.

Tests: tests/php/test_default_password.php (the helpers, and that the
seeded hash is the documented default), tests/zm_user_default_password.cpp.
2026-10-16 10:10:25 -05:00
Nic BoetandClaude Opus 5.5 483700dfdd fix: compare stored plain and MySQL password hashes with hash_equals()
validateUser() compared a plain-text Password column and a MySQL
PASSWORD() hash with ==. Two strings that look like numbers compare as
numbers under ==, so a plain-text password stored as "100" also accepted
"1e2", "100.0" and " 100". Use hash_equals(), which compares the bytes in
constant time; a password that isn't a string is refused.

ZoneMinder stores new passwords as bcrypt, so only accounts whose
Password column was set by hand or carried over from old installs use
these branches. bcrypt still goes through password_verify().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:57:14 -05:00
Nic BoetandClaude Opus 5.5 20141a6276 fix: refuse a reCAPTCHA login cleanly when siteverify can't be reached
do_post_request() throws when Google's siteverify can't be reached or
answers with an HTTP error. The login code didn't catch it, so the login
form got a 500 page and the reason went only to the web server's error
log. Catch it, log an Error naming the URL, and refuse the login the way
a failed challenge is refused. Logins were refused in this case before
too; only the page and the log line change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:57:14 -05:00
Nic BoetandClaude Opus 5.5 6899903d46 fix: let a reCAPTCHA login through only on an explicit success
With ZM_OPT_USE_GOOG_RECAPTCHA on, a password login was refused only when
siteverify answered success == false together with an error-codes list.
An answer without a success field, one that wasn't JSON, success:false
without error-codes, or success:"false" all let the login go ahead as if
the challenge had been solved.

Move the decision into recaptchaAllowsLogin() and allow only
success === true. The existing allowance for a wrong secret key
(invalid-input-secret) is kept, so a mistyped key still logs an Error
instead of locking everyone out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:57:14 -05:00
Nic BoetandClaude Opus 5.5 4183263cfe fix: URL-encode the username in the auth relay
get_auth_relay() builds the query string the web UI appends to stream,
AJAX and multi-server URLs. It encoded the plain relay's password but put
the username in as typed, under all three relays. A username containing
& or = split into extra parameters, and zms ends each value at the next =.
Under plain and hashed that user's streams were refused; under none they
ran as whichever user the rest of the name spelled out.

Encode the username as well. zms decodes user=/username= (UriDecode) and
PHP decodes $_REQUEST, so names without reserved characters are unchanged
on the wire and names with them now arrive whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:57:14 -05:00
Nic BoetandClaude Opus 5.5 16ffaa5384 fix: compare the auth hash with hash_equals() in getAuthUser()
getAuthUser() compared the auth= hash a client presents with the one it
builds using ==. When both strings look like numbers PHP compares them as
numbers, so a computed md5 of the form 0e<digits> equalled any other
0e<digits> string (and "00"), and == also returns at the first byte that
differs. hash_equals() compares the bytes in constant time.

A hash of that form comes up about once in 3*10^8 user-hours, so this is
hardening; ordinary hashes compare exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:57:14 -05:00
Isaac ConnorandClaude Opus 5.5 0e9f82513c fix: bind the FOSCAMR2C preset and require Control permission for PTZ refs GHSA-qcm7-vq92-f86f
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>
2026-10-08 18:21:32 -04:00
nicandClaude Opus 5.5 ad3d5ec54d fix: random per-user salts in zmupdate.pl password migration, rehash migrated passwords on every login (#5167)
* 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>
2026-10-02 08:30:43 -04:00
Isaac ConnorandClaude Opus 5.5 e99a0531a1 fix: replace and refuse the shipped ZM_AUTH_HASH_SECRET refs GHSA-wmcc-x64g-jr84 GHSA-p2c6-jw8f-ffjc
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>
2026-09-28 19:47:22 -04:00
Isaac Connor e924bfb999 fix: treat a request parameter that is not a string as absent in auth
Saving a user from the web ui took the whole auth path down:

  PHP Fatal error: Uncaught TypeError: strcasecmp(): Argument #1 ($string1)
  must be of type string, array given in includes/auth.php:197
  #0 auth.php(197): strcasecmp()
  #1 auth.php(528): getAuthUser()
  #2 auth.php(687): userFromSession()

reached from ?view=user&uid=2. That page's form posts user[Username],
user[Password], user[Name] and the rest, so $_REQUEST['user'] is an array on
every save from it, and getAuthUser() read that parameter as the username to
filter on and handed it to strcasecmp(). Under PHP 8 a string function given an
array is a TypeError rather than a warning, so the request died with a 500.

The same shape arrives from anyone who cares to send it, and not only on a page
that needs a session. userFromSession() reads user, pass, username, password
and auth straight out of the request, and the credential branches run before
anyone is logged in, so ?username[]=x&password[]=y reaches validateUser() with
arrays on an install that has never seen the caller before.

requestString() returns a parameter only when it is a string and null
otherwise, which is what the callers already do with a parameter that was not
sent. An array is not a username, a password or an auth hash.

master no longer has the strcasecmp line the report names, so it does not fatal
in that exact spot, but it reads the same unvalidated values: $filterUser is
bound as a query parameter and the credentials still reach validateUser(). This
fixes the class rather than the one line, and backports to 1.38 where the
reported line lives.

The test lifts requestString() out of auth.php and evaluates it alone, because
including auth.php needs a database; test_auth_no_include_side_effects.php
sidesteps the same dependency the same way. 8 cases, covering the form's array,
the login parameters, a nested array and the strings that must still pass
through. 5 of them fail without this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 1f4aa1a16c1b63fdf34a6f2c6aef6590dbd45acd)
2026-09-24 19:44:16 -04:00
Isaac ConnorandClaude Opus 5 b9f565854f refactor: stop authenticating the request when auth.php is included
auth.php ended in a 130-line block at file scope, so merely including the file
authenticated the current request: it read $_REQUEST, opened a session, queried
the database, and on a login could rewrite the user's stored password hash and
populate $_SESSION. Any caller that wanted one of the functions in the file got
all of that as a side effect, and the order of includes decided when it ran.
HostController requires auth.php twice purely to reach generateAuthHash() and
validateToken().

Move the block into zm_authenticate_request() and call it explicitly from the
two places that want it, web/index.php and AppController::beforeFilter(). The
function returns the ZM\User or null and still sets the global $user, so the
views, ajax handlers and API controllers that read that global are unaffected.
HostController now gets only the function definitions from its requires, which
is all it ever wanted.

Inside a function the five `unset($user)` calls would drop the local binding
and leave the global set, so they become `$user = null` - the idiom the rest of
the file already uses for this, and one that keeps isset($user) false for the
gate at index.php:255. The block's other locals ($ret, $username, $password,
$sql) no longer leak into the caller's scope, which in beforeFilter() means they
can no longer collide with the variables of the same name it assigns just after.

The body is otherwise unchanged; `git diff -w` shows only the wrapper, those
five assignments and the return.

Tests: tests/php/test_auth_no_include_side_effects.php tokenises auth.php and
asserts nothing executes at file scope, with a fixture check so a broken
detector cannot pass vacuously. 4 assertions, all pass. Verified it reports the
pre-refactor file's file-scope block, so it would have caught this.

Not covered by tests: the login, logout, auth-hash and API token flows this
touches. auth.php cannot be included without a database (User.php pulls in
database.php, which connects at include time), so the check is structural.
Needs manual testing on an installed tree before merging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01477mR97vfnK6zczbHgzq6T
2026-08-17 20:52:10 -04:00
Isaac ConnorandClaude Opus 5 935d0cf385 fix: keep an auth hash valid when the client address changes refs #4921
ZM_AUTH_HASH_IPS binds the auth hash to the client address. When that address
changes mid-session - a phone moving between wifi and cellular is the common
case - the hash the browser is still holding no longer matches the address we
now see, and the user is bounced to the login page. The usual workaround is to
turn ZM_AUTH_HASH_IPS off entirely.

Accept the address the request arrives from plus the one it arrived from
immediately before, so an in-flight hash validates once and generateAuthHash()
then reissues against the new address. Addresses are matched exactly. A netmask
was considered and rejected: accepting a whole subnet would let any other host
on the client's network replay a stolen hash, which on a home LAN includes the
cameras themselves.

The previous address is only accepted for as long as a hash issued to it would
itself still be valid (ZM_AUTH_HASH_TTL), so this widens which address is
accepted without extending how long any hash lives. A login clears it, since
nothing from before a privilege boundary should stay acceptable, and only one
previous address is ever retained.

userFromSession() needed the same treatment: it looks the cached hash up by the
live address, so after a change the slot does not exist yet and the user was
reported as not logged in regardless of what getAuthUser() would have accepted.

Also centralises the X-Forwarded-For/REMOTE_ADDR handling in getRemoteAddr(),
replacing four duplicated copies across session.php and auth.php. Those copies
sat on both the generation and validation sides, so any drift between them broke
authentication outright behind a reverse proxy. Network.php holds only that
address parsing; which addresses an auth hash is accepted from is auth policy
and lives in auth.php.

This is web-side only; zms has no session, so a stream request still fails once
on an address change and recovers through the existing auth-refresh path in
MonitorStream.js.

Tests: tests/php/test_remote_addr.php covers getRemoteAddr() parsing and the
session address rotation, and needs no config or database - 18 assertions, all
pass. tests/php/test_auth_hash_candidate_addrs.php covers the acceptance window
including both sides of the TTL boundary; it bootstraps config.php as the other
tests in that directory do and so needs an installed tree to run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01477mR97vfnK6zczbHgzq6T
2026-08-17 20:52:10 -04:00
Isaac ConnorandClaude Opus 4.8 6b94903e63 fix: prevent auth bypass when token validation fails
validateToken() returns array(false, $errorMessage) when a token is
invalid or its signature fails. The token branch in auth.php assigned
$user = $ret[0] unconditionally, leaving $user as boolean false on
failure. Because isset($false) is true in PHP, the ZM_OPT_USE_AUTH gate
in index.php (!isset($user)) was skipped, allowing unauthenticated
access via any malformed ?token= value. It also permitted an
unauthenticated DoS: downstream $user->Username() on a bool fatals.

Check !$ret[0] and unset($user) on failure, mirroring the existing
validateUser branch, so $user stays undefined and the auth gate blocks
the request. The API call sites already throw UnauthorizedException on
!$user and are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:20:16 -04:00
Isaac ConnorandClaude Opus 4.8 bac9b8af01 fix: stop IP-less auth hash from poisoning the IP-bound cache slot refs #4921
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>
2026-06-16 21:07:44 -04:00
SteveGilvarryandClaude Opus 4.8 5ae582b1a1 fix: emit a single session cookie on login refs #2471
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>
2026-06-12 20:25:49 +10:00
Isaac Connor 671e1c361f fix: stop warning on URL/session user mismatch in getAuthUser
The auth hash is stateless (HMAC over secret+username+password+IP+date)
and intentionally independent of the PHP session. Stream/image URLs
minted while user A was logged in keep working for user A's hash even
after the active session has rotated to B (re-login, session timeout +
new login, another tab, etc.), which is the design.

The eager Warning was firing on these legitimate cross-session requests
and producing log noise. A genuinely tampered request without a valid
hash still falls through to the existing failure-path Info log, which
now also reports sessionUser for diagnostics.
2026-05-15 23:01:00 -04:00
Isaac ConnorandClaude Opus 4.7 fbf73de262 fix: align auth hash validation with generation and warn on user mismatch
- AppController.php: stop overwriting $_SESSION['remoteAddr'] with bare
  REMOTE_ADDR right after zm_session_start() already populated it from
  HTTP_X_FORWARDED_FOR. The clobber bound generated hashes to the proxy
  IP, but getAuthUser() validates against XFF, so any hash produced
  inside the legacy stateful API path was DOA behind a reverse proxy.
- getAuthUser(): prefer the URL user= parameter over
  \$_SESSION['username'] for filtering, matching what zms's
  zmLoadAuthUser does, and honor ZM_CASE_INSENSITIVE_USERNAMES on the
  primary filter. Warn when the URL user= disagrees with the session
  username (stale hash, cross-tab contamination, or tampered request).
- Add a Debug input dump on entry and an Info-level failure line that
  reports filterUser, XFF, REMOTE_ADDR, rowsTried and the TTL window so
  the next 401 surfaces which input is wrong.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:46:01 -04:00
copilot-swe-agent[bot]andconnortechnology 5b51d086e1 fix: use HTTP_X_FORWARDED_FOR in auth hash validation to fix AUTH_HASH_IPS with reverse proxy
When AUTH_HASH_IPS is enabled and ZoneMinder is behind a reverse proxy
(e.g. Nginx in front of Apache), the hash is generated using
HTTP_X_FORWARDED_FOR (the real client IP) but was validated using only
REMOTE_ADDR (the proxy's IP), causing all authentication to fail.

Fix by consistently using HTTP_X_FORWARDED_FOR (first IP only, to guard
against spoofed multi-value headers) with REMOTE_ADDR as fallback in
all three places:
- web/includes/session.php: where remoteAddr is stored for hash generation
- web/includes/auth.php: getAuthUser() validation (PHP, also used by zms CGI)
- src/zm_user.cpp: zmLoadAuthUser() validation (C++ zms binary)

refs #4758

Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/959dfe9d-edea-4de5-a3a0-f90b758e5628

Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com>
2026-05-04 14:53:38 +00:00
Isaac ConnorandClaude Opus 4.6 5561829450 fix: include username in auth relay and fix stale auth in stream restart
- Add user= parameter to get_auth_relay() so zms can use the indexed
  Username column instead of iterating all users to validate the hash
- Apply the same fix to Event.php getStreamSrc() and getThumbnailSrc()
- Tighten Monitor.php from isset() to !empty() for consistency
- In MonitorStream.js start(), check if the auth hash in the img src
  matches the current auth_hash before resuming via CMD_PLAY. If stale,
  fall through to rebuild the URL with fresh auth_relay. This prevents
  long-running montage pages from spawning zms with expired credentials.
- Downgrade zms auth failure from Error to Warning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:01:47 -04:00
Isaac ConnorandClaude Opus 4.6 3432e39c45 feat: add Remember Me checkbox to login page
Add ZM_OPT_USE_REMEMBER_ME config option (auth section, requires
ZM_OPT_USE_AUTH) that controls whether a Remember Me checkbox appears
on the login form. When enabled and unchecked, the session cookie
lifetime is set to 0 so the browser discards it on close, logging the
user out. When checked, the session persists for ZM_COOKIE_LIFETIME.
When the option is disabled, behavior is unchanged.

- ConfigData.pm.in: new ZM_OPT_USE_REMEMBER_ME boolean option
- login.php: checkbox between password field and reCAPTCHA/submit
- session.php: use lifetime=0 when remember me is off
- actions/login.php: set/clear ZM_REMEMBER_ME cookie on login, also
  update $_COOKIE so zm_session_start sees it in the same request
- auth.php: clear ZM_REMEMBER_ME cookie on logout
- en_gb.php: add RememberMe translation string

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:41:25 -05:00
Isaac Connor c2ab47ae0d Don't audit log logins 2026-02-23 22:36:03 -05:00
Isaac ConnorandClaude Opus 4.6 e6ace6fcf4 feat: add AUDIT logging level for tracking administrative changes
Add a new AUDIT logging level (-5) between PANIC (-4) and NOLOG (shifted
to -6) across C++, PHP, and Perl loggers. AUDIT entries use code 'AUD'
and syslog priority LOG_NOTICE. They record who changed what, from where,
for monitors, filters, users, config, roles, groups, zones, states,
servers, storage, events, snapshots, control caps, and login/logout.

AUDIT entries have their own retention period (ZM_LOG_AUDIT_DATABASE_LIMIT,
default 1 year) separate from regular log pruning. The log pruning in
zmstats.pl and zmaudit.pl now excludes AUDIT rows from regular pruning
and prunes them independently.

Critical safety: the C++ termination logic is changed from
'if (level <= FATAL)' to 'if (level == FATAL || level == PANIC)' to
prevent AUDIT-level log calls from killing the process.

Includes db migration zm_update-1.39.1.sql to shift any stored NOLOG
config values from -5 to -6.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 18:19:20 -05:00
Isaac ConnorandClaude Opus 4.5 60fbea3880 fix: security and code quality improvements in auth.php
- Fix SQL injection vulnerability in migrateHash() by using prepared statements
- Add null/empty check in password_type() to prevent array access error
- Remove dead code branch in generateAuthHash() (unreachable $_SESSION check)
- Fix PHP version in error message (5.3 -> 5.5 for password_hash)
- Prevent username enumeration by using consistent error messages
- Fix spacing inconsistency in substr() call
- Add TODO comment about MD5 hash weakness

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 15:10:55 -05:00
Isaac ConnorandClaude Opus 4.5 4e60cb96a7 feat: add User Roles feature for reusable permission templates
Add a User Roles system where roles define reusable permission templates.
When a user has a role assigned, the role provides fallback permissions
(user's direct permissions take precedence; role is used when user has 'None').

Database changes:
- Add User_Roles table with same permission fields as Users
- Add Role_Groups_Permissions table for per-role group overrides
- Add Role_Monitors_Permissions table for per-role monitor overrides
- Add RoleId foreign key to Users table

Permission resolution order:
1. User's direct Monitor/Group permissions (if not 'Inherit')
2. Role's Monitor/Group permissions (if user has role)
3. Role's base permission (if user's is 'None')
4. User's base permission (fallback)

Includes:
- PHP models: User_Role, Role_Group_Permission, Role_Monitor_Permission
- Role management UI in Options > Roles tab
- Role selector in user edit form
- REST API endpoints for roles CRUD
- Translation strings for en_gb

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 13:34:27 -05:00
Isaac Connor 50fbbb0154 When logging in by auth hash, clear old auth in session. 2026-01-04 18:27:14 -05:00
Isaac Connor 3957a212f8 Dont error when using auth_relay=none and there is no username in session 2025-11-10 13:47:50 -05:00
Isaac Connor b1e7266319 Turn off debug 2025-09-06 11:38:50 -04:00
Isaac Connor ca8a3048ed Turn off debug 2025-09-06 11:38:40 -04:00
Isaac Connor b5e1d5e611 Fix invalid canEditMonitor call, simplify login in canView and canEdit to prefer per-monitor permissions 2024-03-05 14:02:01 -05:00
Isaac Connor 31af9d905c Still generate an auth if no password present. 2024-02-08 13:31:21 -05:00
Isaac Connor 0d6e7bc082 Add debugging to editableMonitor and handle when permission is set to Create. 2024-01-21 11:28:34 -05:00
Isaac Connor 31036ae9dd Users with Create permission should be able to view console 2024-01-03 16:41:56 -05:00
Isaac Connor 2d49e93405 Introduce a Create permission for Monitors, so that a given user may Edit existing monitors, but not create new ones. 2024-01-02 12:18:09 -05:00
Isaac Connor 9f04d90a27 When ZM_AUTH_HASH_IPS is off, don't use remote ip in storing auth hash in session. If ips are constantly changing it breaks. 2023-11-13 11:31:45 -05:00
Isaac Connor c6a7fe949a Use different syntax for property access 2023-09-13 13:10:01 -04:00
Isaac Connor 8748d6a1cc Handle unset _SESSION['remoteAddr'] 2023-08-16 15:23:34 -04:00
Isaac Connor 91b4e299b9 Demote failed login to Warn instead of Error 2023-08-16 10:37:06 -04:00