Files
zoneminder/web/includes/Network.php
T
Isaac Connor 9f3f6c6770 fix: only trust X-Forwarded-For from configured proxies for auth hash IPs
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)
2026-09-24 19:51:27 -04:00

45 lines
2.0 KiB
PHP

<?php
//
// ZoneMinder network address helpers
//
// Kept dependency-free so it can be included from anywhere - session.php pulls
// it in before the rest of the web bootstrap exists - and unit tested
// standalone. Policy decisions that happen to involve addresses do not belong
// here; the auth hash's use of getRemoteAddr() lives in auth.php.
//
// Return the effective client address.
//
// X-Forwarded-For is client-settable, so it is only honoured when the request
// arrives from one of ZM_AUTH_TRUSTED_PROXIES. The header is then walked from
// the right, skipping hops that are themselves trusted proxies, and the first
// untrusted hop is the client. Left-most values are never preferred: a proxy
// appends to whatever the client sent, so they are attacker-chosen. With no
// trusted proxies configured (the default) this is always REMOTE_ADDR.
//
// src/zm_utils.cpp ClientAddress() implements the same rule for zms; the two
// must agree or IP-bound auth hashes fail to validate.
//
// $trustedProxies defaults to the ZM_AUTH_TRUSTED_PROXIES config and is a
// parameter only so the tests can vary it.
//
// ponytail: exact address match only. Add CIDR ranges if proxies on dynamic
// addresses (e.g. container networks) need it.
function getRemoteAddr($trustedProxies=null) {
if ($trustedProxies === null) {
$trustedProxies = defined('ZM_AUTH_TRUSTED_PROXIES') ? ZM_AUTH_TRUSTED_PROXIES : '';
}
$remoteAddr = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
$trusted = preg_split('/[\s,]+/', $trustedProxies, -1, PREG_SPLIT_NO_EMPTY);
if (empty($_SERVER['HTTP_X_FORWARDED_FOR']) or !in_array($remoteAddr, $trusted, true)) {
return $remoteAddr;
}
$hops = array_values(array_filter(array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])), 'strlen'));
for ($i = count($hops) - 1; $i >= 0; $i--) {
if (!in_array($hops[$i], $trusted, true)) return $hops[$i];
}
// Every hop is a trusted proxy; the left-most is the origin.
return count($hops) ? $hops[0] : $remoteAddr;
}
?>